From d709ec86235ec37ebbd7205adf34b939c45ded2d Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 09:12:25 +0200 Subject: [PATCH 1/3] log(oura): say WHICH SpO2 channel a first-decoded line is reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Oura: first SpO2 decoded (last night) - value N (unit)` fired on whichever `.spo2` event the history drain happened to serve first. Two decoders feed that event with quantities three orders of magnitude apart — 0x6F/0x7B carry a firmware-computed percentage, 0x77 carries a raw DC perfusion magnitude — so on one ring, minutes apart, the log read `value 93 (raw)` and then `value 101144 (dc_raw)`. A reporter read the five-digit one as a percentage and opened a defect against SpO2 that was never wrong; their capture in fact carried 5,089 0x6F packets beside 12,646 0x77, with the percentage channel live all night. `OuraSpO2Channel` (OuraProtocol + com.noop.oura) resolves the channel from the unit tag and owns both the label and the log line, so the two platforms cannot disagree about what they call these numbers. One latch per channel replaces the single `loggedFirstSpo2` flag, so each line names one quantity and a session that only ever saw perfusion says so instead of implying a percentage arrived: Oura: first SpO2 percentage decoded (last night) - 93 % (channel "raw") Oura: first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw") The `%` is appended on the percentage channel only — `-288 %` would be the same bug in a new costume, and a test pins that. `OuraStreamMapping` keeps its own `unit == "raw"` allow-list on both platforms and is untouched: it is a persistence gate, so an unrecognised future unit must fall on the "do not store" side, whereas this resolver has to name every sample it is handed. Log copy only; no behaviour, no stored value, no new strings. Verified: OuraProtocol 262/0 (10 new, incl. the decoders really stamping those tags); Kotlin `OuraSpO2ChannelOracleTest` 3/3 asserting the verbatim stdout of the shipped Swift `firstDecodedLogLine`; Android 6381 tests, the 8 failures are pre-existing on `upstream/main`; macOS `Strand` build + `StrandTests` TEST SUCCEEDED; iOS `NOOPiOS` BUILD SUCCEEDED; doc lint and `i18n_audit --ci` clean. Refs #2372 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RQ5haNgsWbrEFMpjviYns8 --- .../Sources/OuraProtocol/OuraEvents.swift | 64 +++++++++++ .../OuraSpO2ChannelTests.swift | 92 ++++++++++++++++ Strand/BLE/OuraLiveSource.swift | 21 ++-- .../main/java/com/noop/ble/OuraLiveSource.kt | 26 +++-- .../src/main/java/com/noop/oura/OuraEvents.kt | 74 +++++++++++++ .../noop/oura/OuraSpO2ChannelOracleTest.kt | 103 ++++++++++++++++++ 6 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift create mode 100644 android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift index 4c5022c931..0fc2f76917 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift @@ -120,6 +120,70 @@ public struct OuraSpO2: Equatable, Sendable, Codable { } } +/// Which physical quantity an `OuraSpO2.unit` tag describes. +/// +/// The ring sends TWO things down the same `.spo2` event and they are three orders of magnitude apart: +/// 0x6F/0x7B carry a firmware-computed PERCENTAGE, and 0x77 carries a raw DC perfusion magnitude +/// (-1,016 … 11,709,098 in one overnight capture). Only the unit tag survives decode to tell them +/// apart, so anything that reports a value to a human has to ask this before it names it. +/// +/// WHY THIS TYPE EXISTS RATHER THAN A `unit == "raw"` CHECK AT EACH SITE. The strap log's +/// "first SpO2 decoded" line printed whichever sample the drain happened to serve first, with no +/// channel in the text — so on one reconnect it read `value 93 (raw)` and on the next +/// `value 101144 (dc_raw)`, from the same ring, minutes apart. A reporter read the five-digit one as a +/// percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what +/// it can attribute; this makes the attribution a value rather than a string comparison. +/// +/// `OuraStreamMapping` (both platforms) reads the same fact for a different purpose and deliberately +/// keeps its own `unit == "raw"` ALLOW-LIST: it is a persistence gate, so an unrecognised future unit +/// must fall on the "do not store" side, whereas this resolver has to name every sample it is given. +/// Same fact, two dispositions — the difference is intentional. Kotlin twin: `OuraSpO2Channel`. +public enum OuraSpO2Channel: String, Equatable, Sendable, Codable { + /// 0x6F / 0x7B — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`, + /// which names the CHANNEL, not the quantity; see `decodeSpO2Event`.) + case percentage + /// 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. + case perfusion + + /// The unit tag 0x77 stamps on its samples. + public static let perfusionUnit = "dc_raw" + + /// Resolve a sample's channel from its unit tag. Anything that is not the perfusion tag is treated + /// as the percentage channel, matching how 0x6F and 0x7B both default to `"raw"`. + public static func forUnit(_ unit: String) -> OuraSpO2Channel { + unit == perfusionUnit ? .perfusion : .percentage + } + + /// How this channel is named in the strap log. Spelled out rather than printed as the unit tag: + /// `"raw"` names the CHANNEL, not the quantity, and reads as "unprocessed" to everyone who has not + /// read `decodeSpO2PerSample`. + public var logLabel: String { + switch self { + case .percentage: return "SpO2 percentage" + case .perfusion: return "SpO2 raw DC perfusion (NOT a percentage)" + } + } + + /// The strap-log body for "the first sample of this channel arrived this session", WITHOUT either + /// platform's `Oura: ` prefix (each source adds its own, as it does for every other line). + /// + /// It lives here, not at the two call sites, for the reason the whole type exists: the two platforms + /// must not be able to disagree about what they call these numbers. The unit tag is still printed, + /// so a log can be matched back to the decoder, and the `%` is appended ONLY on the percentage + /// channel — a perfusion magnitude with a `%` on it is the original bug in a new costume. + /// Kotlin twin: `OuraSpO2Channel.firstDecodedLogLine`, oracle-tested against this function's output. + public static func firstDecodedLogLine(value: Int, unit: String) -> String { + let c = forUnit(unit) + return "first \(c.logLabel) decoded (last night) - \(value)\(c == .percentage ? " %" : "")" + + " (channel \"\(unit)\")" + } +} + +public extension OuraSpO2 { + /// What this sample's number actually is. See `OuraSpO2Channel`. + var channel: OuraSpO2Channel { OuraSpO2Channel.forUnit(unit) } +} + /// One decoded skin-temperature sample in hundredths of a degree C scaled to C (value already / 100). public struct OuraTemp: Equatable, Sendable, Codable { public let ringTimestamp: UInt32 diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift new file mode 100644 index 0000000000..5aefb88bab --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import OuraProtocol + +/// `OuraSpO2Channel` is the one resolver that says what an SpO2 sample's number IS. It exists because +/// the strap log used to print a 0x77 perfusion magnitude and a 0x6F percentage under the same words, +/// on the same ring, minutes apart. These tests pin the two things that made that possible: the unit +/// tags the decoders actually stamp, and the resolver's disposition for each. +final class OuraSpO2ChannelTests: XCTestCase { + + // MARK: - The resolver itself + + func testPerfusionUnitResolvesToPerfusion() { + XCTAssertEqual(OuraSpO2Channel.forUnit("dc_raw"), .perfusion) + XCTAssertEqual(OuraSpO2Channel.perfusionUnit, "dc_raw") + } + + func testPercentageUnitResolvesToPercentage() { + XCTAssertEqual(OuraSpO2Channel.forUnit("raw"), .percentage) + } + + /// The gate is an ALLOW-LIST for perfusion, not for percentage: an unrecognised future unit is named + /// as a percentage rather than left unnamed, because this resolver has to name everything it is + /// given. `OuraStreamMapping` makes the opposite call for the opposite reason (it stores), and that + /// difference is deliberate — if this ever changes, that comment has to change with it. + func testUnknownUnitIsNamedAsPercentageNotPerfusion() { + XCTAssertEqual(OuraSpO2Channel.forUnit("raw_adc"), .percentage) + XCTAssertEqual(OuraSpO2Channel.forUnit(""), .percentage) + XCTAssertEqual(OuraSpO2Channel.forUnit("DC_RAW"), .percentage) // case-sensitive, like the tag + } + + func testSampleAccessorMatchesTheResolver() { + let pct = OuraSpO2(ringTimestamp: 1, value: 93) + let dc = OuraSpO2(ringTimestamp: 1, value: 101_144, unit: "dc_raw") + XCTAssertEqual(pct.channel, .percentage) + XCTAssertEqual(dc.channel, .perfusion) + } + + // MARK: - The log line + + /// The exact strings a reader will see. Pinned here so a rename on THIS side is a failing test + /// rather than a silent divergence from `OuraSpO2ChannelOracleTest`, whose literals are this + /// function's own stdout. The two directions together are what stop either platform drifting. + func testFirstDecodedLogLineText() { + XCTAssertEqual(OuraSpO2Channel.firstDecodedLogLine(value: 93, unit: "raw"), + #"first SpO2 percentage decoded (last night) - 93 % (channel "raw")"#) + XCTAssertEqual(OuraSpO2Channel.firstDecodedLogLine(value: 101_144, unit: "dc_raw"), + #"first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")"#) + } + + /// A negative perfusion magnitude is real (the 0x77 accumulator goes below zero) and must not pick + /// up a `%`. This is the original defect in its most misleading form: `-288 %`. + func testNegativePerfusionNeverGetsAPercentSign() { + let line = OuraSpO2Channel.firstDecodedLogLine(value: -288, unit: "dc_raw") + XCTAssertFalse(line.contains("%"), line) + XCTAssertTrue(line.contains("NOT a percentage"), line) + } + + /// The percentage channel keeps its unit tag in the text, so a log still ties back to the decoder + /// even though the words no longer repeat the tag. + func testPercentageLineStillNamesItsUnitTag() { + XCTAssertTrue(OuraSpO2Channel.firstDecodedLogLine(value: 95, unit: "raw").contains(#"channel "raw""#)) + } + + // MARK: - The decoders really do stamp those tags + + /// Without this the resolver could be right about strings nothing produces. 0x6F yields the + /// percentage channel; 0x77 yields perfusion. Both are fed real-shaped bodies, not hand-set units. + func testDecodedSpO2PerSampleIsThePercentageChannel() throws { + let rec = OuraRecord(type: 0x6F, ringTimestamp: 100, payload: [0x00, 95, 96, 97]) + let out = try XCTUnwrap(OuraDecoders.decodeSpO2PerSample(rec)) + XCTAssertFalse(out.isEmpty) + XCTAssertTrue(out.allSatisfy { $0.channel == .percentage }, "0x6F must read as a percentage") + } + + func testDecodedSpO2DCIsThePerfusionChannel() throws { + // hasBase (bit 6) + a 24-bit LE base, then one sign-magnitude delta. + let rec = OuraRecord(type: 0x77, ringTimestamp: 100, + payload: [0x40, 0x2C, 0xA0, 0x00, 0x05]) + let out = try XCTUnwrap(OuraDecoders.decodeSpO2DC(rec)) + XCTAssertFalse(out.isEmpty) + XCTAssertTrue(out.allSatisfy { $0.channel == .perfusion }, "0x77 must read as perfusion") + // And the magnitudes really are the three-orders-of-magnitude-apart kind that started this. + XCTAssertGreaterThan(out[0].value, 100, "a perfusion base is not a percentage") + } + + /// 0x7B carries a single BIG-endian value and takes the default unit, so it is a percentage too. + func testDecodedSpO2StableIsThePercentageChannel() throws { + let rec = OuraRecord(type: 0x7B, ringTimestamp: 100, payload: [0x00, 0x60]) + let s = try XCTUnwrap(OuraDecoders.decodeSpO2Stable(rec)) + XCTAssertEqual(s.channel, .percentage) + } +} diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index c089011816..5415d10c2c 100644 --- a/Strand/BLE/OuraLiveSource.swift +++ b/Strand/BLE/OuraLiveSource.swift @@ -329,8 +329,14 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// stop/disconnect. These are last-night values from the history fetch, not live pushes, but we still /// only want one log line, not one per sample. Twin of `loggedFirstHR`. private var loggedFirstTemp = false - /// Logs the FIRST SpO2 sample decoded this session only. Twin of `loggedFirstTemp`. - private var loggedFirstSpo2 = false + /// Logs the FIRST SpO2 sample decoded this session, PER CHANNEL. Twin of `loggedFirstTemp`, except + /// that `.spo2` carries two quantities three orders of magnitude apart (`OuraSpO2Channel`), and one + /// latch across both reported whichever the drain served first: the same ring printed `value 93 + /// (raw)` on one reconnect and `value 101144 (dc_raw)` on the next. A reporter read the second as a + /// percentage and filed a defect against SpO2 that was never wrong. One latch per channel, so each + /// line names one quantity and a session that only ever saw perfusion says so instead of implying a + /// percentage arrived. + private var loggedFirstSpo2: Set = [] /// The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs /// seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's /// clock had run past the window. Retried against the drain's `maxSeenRingTime` as the first batch @@ -1604,7 +1610,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false pendingSyncTime = nil loggedTierBKinds.removeAll() @@ -2107,9 +2113,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { } case .spo2(let s): - if !loggedFirstSpo2 { - loggedFirstSpo2 = true - log("Oura: first SpO2 decoded (last night) - value \(s.value) (\(s.unit))") + if loggedFirstSpo2.insert(s.channel).inserted { + log("Oura: " + OuraSpO2Channel.firstDecodedLogLine(value: s.value, unit: s.unit)) } if let ts = driver.unixSeconds(forRingTimestamp: s.ringTimestamp) { enqueue([e], ts: ts) @@ -2779,7 +2784,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false pendingSyncTime = nil loggedTierBKinds.removeAll() @@ -2875,7 +2880,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false pendingSyncTime = nil loggedTierBKinds.removeAll() diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 12f739782a..0982b3fa8a 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -41,6 +41,8 @@ import com.noop.oura.OuraOuterFrame import com.noop.oura.OuraReassembler import com.noop.oura.OuraRingGen import com.noop.oura.OuraSleepSession +import com.noop.oura.OuraSpO2Channel +import com.noop.oura.channel import com.noop.oura.OuraSleepSessionMapping import com.noop.oura.OuraTransition import com.noop.oura.OuraWearState @@ -328,8 +330,14 @@ class OuraLiveSource( * stop/disconnect. These are last-night values from the history fetch, not live pushes, but we still * only want one log line, not one per sample. Twin of [loggedFirstHr]. */ private var loggedFirstTemp = false - /** Logs the FIRST SpO2 sample decoded this session only. Twin of [loggedFirstTemp]. */ - private var loggedFirstSpo2 = false + /** Logs the FIRST SpO2 sample decoded this session, PER CHANNEL. Twin of [loggedFirstTemp], except + * that `.spo2` carries two quantities three orders of magnitude apart ([OuraSpO2Channel]), and one + * latch across both reported whichever the drain served first: the same ring printed `value 93 + * (raw)` on one reconnect and `value 101144 (dc_raw)` on the next. A reporter read the second as a + * percentage and filed a defect against SpO2 that was never wrong. One latch per channel, so each + * line names one quantity and a session that only ever saw perfusion says so instead of implying a + * percentage arrived. Twin of Swift's `loggedFirstSpo2` set. */ + private val loggedFirstSpo2 = mutableSetOf() /** The 0x13 SyncTime reply parked because nothing yet available could disambiguate its unit (ticks vs * seconds x10): the resume cursor was 0 (fresh pair / post-reboot full pull) or so stale the ring's * clock had run past the window. Retried against the drain's maxSeenRingTime as the first batch lands @@ -1200,7 +1208,7 @@ class OuraLiveSource( authEscalations = 0 authToggleCccdPending = 0 loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false pendingSyncTime = null loggedTierBKinds.clear() @@ -1302,7 +1310,7 @@ class OuraLiveSource( reassembler.reset() loggedFirstHr = false // a later reconnect should log its first sample again loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false pendingSyncTime = null loggedTierBKinds.clear() @@ -1454,7 +1462,7 @@ class OuraLiveSource( dropUnanchoredHypnogramBursts() reassembler.reset() loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false pendingSyncTime = null loggedTierBKinds.clear() @@ -2060,9 +2068,11 @@ class OuraLiveSource( } } is OuraEvent.Spo2 -> { - if (!loggedFirstSpo2) { - loggedFirstSpo2 = true - log("Oura: first SpO2 decoded (last night) - value ${e.value.value} (${e.value.unit})") + if (loggedFirstSpo2.add(e.value.channel)) { + log( + "Oura: " + + OuraSpO2Channel.firstDecodedLogLine(e.value.value, e.value.unit), + ) } enqueueAnchoredOrPark(e, e.value.ringTimestamp, d) } diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index bdf94fb0c8..7439acc8b8 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -128,6 +128,80 @@ data class OuraSpO2( val count: Int = 1, ) +/** + * Which physical quantity an [OuraSpO2.unit] tag describes. + * + * The ring sends TWO things down the same `.spo2` event and they are three orders of magnitude apart: + * 0x6F/0x7B carry a firmware-computed PERCENTAGE, and 0x77 carries a raw DC perfusion magnitude + * (-1,016 … 11,709,098 in one overnight capture). Only the unit tag survives decode to tell them + * apart, so anything that reports a value to a human has to ask this before it names it. + * + * WHY THIS TYPE EXISTS RATHER THAN A `unit == "raw"` CHECK AT EACH SITE. The strap log's + * "first SpO2 decoded" line printed whichever sample the drain happened to serve first, with no + * channel in the text — so on one reconnect it read `value 93 (raw)` and on the next + * `value 101144 (dc_raw)`, from the same ring, minutes apart. A reporter read the five-digit one as a + * percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what it + * can attribute; this makes the attribution a value rather than a string comparison. + * + * OuraStreamMapping (both platforms) reads the same fact for a different purpose and deliberately keeps + * its own `unit == "raw"` ALLOW-LIST: it is a persistence gate, so an unrecognised future unit must fall + * on the "do not store" side, whereas this resolver has to name every sample it is given. Same fact, two + * dispositions — the difference is intentional. Twin of Swift `OuraSpO2Channel`. + */ +enum class OuraSpO2Channel { + /** + * 0x6F / 0x7B — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`, + * which names the CHANNEL, not the quantity; see `decodeSpO2Event`.) + */ + PERCENTAGE, + + /** 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. */ + PERFUSION, + ; + + /** + * How this channel is named in the strap log. Spelled out rather than printed as the unit tag: + * `"raw"` names the CHANNEL, not the quantity, and reads as "unprocessed" to everyone who has not + * read `decodeSpO2PerSample`. Twin of Swift `OuraSpO2Channel.logLabel`. + */ + val logLabel: String get() = when (this) { + PERCENTAGE -> "SpO2 percentage" + PERFUSION -> "SpO2 raw DC perfusion (NOT a percentage)" + } + + companion object { + /** The unit tag 0x77 stamps on its samples. */ + const val PERFUSION_UNIT = "dc_raw" + + /** + * Resolve a sample's channel from its unit tag. Anything that is not the perfusion tag is + * treated as the percentage channel, matching how 0x6F and 0x7B both default to `"raw"`. + */ + fun forUnit(unit: String): OuraSpO2Channel = + if (unit == PERFUSION_UNIT) PERFUSION else PERCENTAGE + + /** + * The strap-log body for "the first sample of this channel arrived this session", WITHOUT + * either platform's `Oura: ` prefix (each source adds its own, as it does for every other line). + * + * It lives here, not at the two call sites, for the reason the whole type exists: the two + * platforms must not be able to disagree about what they call these numbers. The unit tag is + * still printed, so a log can be matched back to the decoder, and the `%` is appended ONLY on + * the percentage channel — a perfusion magnitude with a `%` on it is the original bug in a new + * costume. Twin of Swift `OuraSpO2Channel.firstDecodedLogLine`; `OuraSpO2ChannelOracleTest` + * asserts this against that function's own stdout. + */ + fun firstDecodedLogLine(value: Int, unit: String): String { + val c = forUnit(unit) + val pct = if (c == PERCENTAGE) " %" else "" + return "first ${c.logLabel} decoded (last night) - $value$pct (channel \"$unit\")" + } + } +} + +/** What this sample's number actually is. See [OuraSpO2Channel]. */ +val OuraSpO2.channel: OuraSpO2Channel get() = OuraSpO2Channel.forUnit(unit) + /** One decoded skin-temperature sample in degrees C (value already / 100). */ data class OuraTemp(val ringTimestamp: Long, val celsius: Double) diff --git a/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt b/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt new file mode 100644 index 0000000000..a1ae50bc11 --- /dev/null +++ b/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt @@ -0,0 +1,103 @@ +package com.noop.oura + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Oracle test for the SpO2 channel resolver and its strap-log line. + * + * WHY AN ORACLE AND NOT A HAND-WRITTEN EXPECTATION. The defect this whole type fixes was two numbers + * three orders of magnitude apart being printed under the same words. Two platforms hand-writing + * "what we call this channel" is how that comes back — one side says "perfusion", the other "DC", and + * nobody notices until a reporter pastes a log and is told the wrong thing. So the literals below are + * NOT written by hand: they are the verbatim stdout of the SHIPPED Swift function, captured with + * + * swift build # in Packages/OuraProtocol + * swiftc -O -I /Products/Debug -L /Products/Debug -lOuraProtocol main.swift -o oracle + * ./oracle + * + * where main.swift prints `value|unit|channel|OuraSpO2Channel.firstDecodedLogLine(value:unit:)` for + * each row. Re-capture and re-paste if the Swift side ever changes; do not edit these by eye. + * + * NOTE ON DIRECTION: this guards Kotlin against the Swift of the day. `OuraSpO2ChannelTests` on the + * Swift side pins the same dispositions, which is what stops Swift drifting silently. + */ +class OuraSpO2ChannelOracleTest { + + /** `value|unit|channel|line` — verbatim Swift stdout. */ + private val oracle = listOf( + """93|raw|percentage|first SpO2 percentage decoded (last night) - 93 % (channel "raw")""", + """94|raw|percentage|first SpO2 percentage decoded (last night) - 94 % (channel "raw")""", + """98|raw|percentage|first SpO2 percentage decoded (last night) - 98 % (channel "raw")""", + """0|raw|percentage|first SpO2 percentage decoded (last night) - 0 % (channel "raw")""", + """100|raw|percentage|first SpO2 percentage decoded (last night) - 100 % (channel "raw")""", + """101144|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")""", + """-288|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - -288 (channel "dc_raw")""", + """41132|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 41132 (channel "dc_raw")""", + """65815|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 65815 (channel "dc_raw")""", + """208|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 208 (channel "dc_raw")""", + """1|raw_adc|percentage|first SpO2 percentage decoded (last night) - 1 % (channel "raw_adc")""", + """2||percentage|first SpO2 percentage decoded (last night) - 2 % (channel "")""", + """3|DC_RAW|percentage|first SpO2 percentage decoded (last night) - 3 % (channel "DC_RAW")""", + ) + + @Test + fun kotlinMatchesTheSwiftOracleRowForRow() { + for (row in oracle) { + val parts = row.split("|", limit = 4) + val value = parts[0].toInt() + val unit = parts[1] + val expectedChannel = parts[2] + val expectedLine = parts[3] + + assertEquals( + "channel for unit \"$unit\"", + expectedChannel, + OuraSpO2Channel.forUnit(unit).name.lowercase(), + ) + assertEquals( + "log line for $value ($unit)", + expectedLine, + OuraSpO2Channel.firstDecodedLogLine(value, unit), + ) + } + } + + /** The sample accessor and the resolver must not be able to disagree. */ + @Test + fun sampleAccessorMatchesTheResolver() { + assertEquals(OuraSpO2Channel.PERCENTAGE, OuraSpO2(ringTimestamp = 1L, value = 93).channel) + assertEquals( + OuraSpO2Channel.PERFUSION, + OuraSpO2(ringTimestamp = 1L, value = 101144, unit = "dc_raw").channel, + ) + } + + /** + * The decoders really do stamp those tags — without this the resolver could be right about strings + * nothing produces. Mirrors the Swift `testDecodedSpO2*` cases. + */ + @Test + fun decodersProduceTheChannelsTheResolverNames() { + val perSample = OuraDecoders.decodeSpO2PerSample( + OuraRecord(type = 0x6F, ringTimestamp = 100L, payload = intArrayOf(0x00, 95, 96, 97)), + ) + assertEquals(true, perSample!!.isNotEmpty()) + assertEquals(true, perSample.all { it.channel == OuraSpO2Channel.PERCENTAGE }) + + val dc = OuraDecoders.decodeSpO2DC( + OuraRecord( + type = 0x77, + ringTimestamp = 100L, + payload = intArrayOf(0x40, 0x2C, 0xA0, 0x00, 0x05), + ), + ) + assertEquals(true, dc!!.isNotEmpty()) + assertEquals(true, dc.all { it.channel == OuraSpO2Channel.PERFUSION }) + + val stable = OuraDecoders.decodeSpO2Stable( + OuraRecord(type = 0x7B, ringTimestamp = 100L, payload = intArrayOf(0x00, 0x60)), + ) + assertEquals(OuraSpO2Channel.PERCENTAGE, stable!!.channel) + } +} From fa1d651d2814dd3a469e525e1e69ce28fbc744dc Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 12:19:55 +0200 Subject: [PATCH 2/3] log(oura): pair forUnit with its twin, and name an unrecognised SpO2 tag as unknown Review follow-up. `forUnit` had no `Kotlin twin:` reference, so the parity ledger counted both sides as one-sided (`add-unpaired-function ... forUnit/1#1`, Swift and Kotlin). The reference is now on both declarations. `forUnit` also treated anything that is not `dc_raw` as a percentage, so a case variant or a future tag would print its magnitude with a `%` on it: the defect this type exists to stop. Both known tags (`raw`, `dc_raw`) now match exactly and anything else resolves to a third `unknown` channel, which names its tag and never appends `%`. Nothing reaches it today (the ring emits only those two tags); this is durability. The per-channel latch gains the matching third slot for free. Verified: OuraProtocol 263/0; Kotlin `OuraSpO2ChannelOracleTest` 3/3 against the re-captured stdout of the shipped Swift (15 rows, 5 of them unknown tags); `parity_ledger.py` leaves only `twin-map-authority-drift|unpaired_properties`, which the reviewer kept; doc lint clean. Refs #2372 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV --- .../Sources/OuraProtocol/OuraEvents.swift | 26 ++++++++++++----- .../OuraSpO2ChannelTests.swift | 25 +++++++++++----- .../src/main/java/com/noop/oura/OuraEvents.kt | 29 ++++++++++++++----- .../noop/oura/OuraSpO2ChannelOracleTest.kt | 13 +++++---- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift index 0fc2f76917..c98ea5c0f8 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift @@ -134,24 +134,35 @@ public struct OuraSpO2: Equatable, Sendable, Codable { /// percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what /// it can attribute; this makes the attribution a value rather than a string comparison. /// -/// `OuraStreamMapping` (both platforms) reads the same fact for a different purpose and deliberately -/// keeps its own `unit == "raw"` ALLOW-LIST: it is a persistence gate, so an unrecognised future unit -/// must fall on the "do not store" side, whereas this resolver has to name every sample it is given. -/// Same fact, two dispositions — the difference is intentional. Kotlin twin: `OuraSpO2Channel`. +/// Both known tags are matched EXACTLY; anything else is `.unknown`, which names its tag and never +/// claims a percentage. Treating "not perfusion" as a percentage would print a case variant or a future +/// tag's magnitude with a `%` on it, which is the defect this type exists to stop. `OuraStreamMapping` +/// (both platforms) keeps its own `unit == "raw"` allow-list for the same reason from the other side: +/// it is a persistence gate, so an unrecognised unit falls on the "do not store" side there and on the +/// "do not call it a percentage" side here. Kotlin twin: `OuraSpO2Channel`. public enum OuraSpO2Channel: String, Equatable, Sendable, Codable { /// 0x6F / 0x7B — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`, /// which names the CHANNEL, not the quantity; see `decodeSpO2Event`.) case percentage /// 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. case perfusion + /// A unit tag no decoder stamps today (a case variant, or a future tag). Named, never a percentage. + case unknown + /// The unit tag 0x6F and 0x7B stamp on their samples (`OuraSpO2`'s default). + public static let percentageUnit = "raw" /// The unit tag 0x77 stamps on its samples. public static let perfusionUnit = "dc_raw" - /// Resolve a sample's channel from its unit tag. Anything that is not the perfusion tag is treated - /// as the percentage channel, matching how 0x6F and 0x7B both default to `"raw"`. + /// Resolve a sample's channel from its unit tag. Both known tags match exactly (case-sensitive, like + /// the tag); anything else is `.unknown` rather than a guess. + /// Kotlin twin: `OuraSpO2Channel.forUnit`. public static func forUnit(_ unit: String) -> OuraSpO2Channel { - unit == perfusionUnit ? .perfusion : .percentage + switch unit { + case percentageUnit: return .percentage + case perfusionUnit: return .perfusion + default: return .unknown + } } /// How this channel is named in the strap log. Spelled out rather than printed as the unit tag: @@ -161,6 +172,7 @@ public enum OuraSpO2Channel: String, Equatable, Sendable, Codable { switch self { case .percentage: return "SpO2 percentage" case .perfusion: return "SpO2 raw DC perfusion (NOT a percentage)" + case .unknown: return "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" } } diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift index 5aefb88bab..3b3b7a096b 100644 --- a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift @@ -18,14 +18,23 @@ final class OuraSpO2ChannelTests: XCTestCase { XCTAssertEqual(OuraSpO2Channel.forUnit("raw"), .percentage) } - /// The gate is an ALLOW-LIST for perfusion, not for percentage: an unrecognised future unit is named - /// as a percentage rather than left unnamed, because this resolver has to name everything it is - /// given. `OuraStreamMapping` makes the opposite call for the opposite reason (it stores), and that - /// difference is deliberate — if this ever changes, that comment has to change with it. - func testUnknownUnitIsNamedAsPercentageNotPerfusion() { - XCTAssertEqual(OuraSpO2Channel.forUnit("raw_adc"), .percentage) - XCTAssertEqual(OuraSpO2Channel.forUnit(""), .percentage) - XCTAssertEqual(OuraSpO2Channel.forUnit("DC_RAW"), .percentage) // case-sensitive, like the tag + /// Both known tags match EXACTLY; anything else is `.unknown`. Treating "not perfusion" as a + /// percentage would print a case variant or a future tag's magnitude with a `%` on it — the defect + /// this type exists to stop, in a new costume. + func testUnknownUnitIsUnknownNotAPercentage() { + XCTAssertEqual(OuraSpO2Channel.forUnit("raw_adc"), .unknown) + XCTAssertEqual(OuraSpO2Channel.forUnit(""), .unknown) + XCTAssertEqual(OuraSpO2Channel.forUnit("DC_RAW"), .unknown) // case-sensitive, like the tag + XCTAssertEqual(OuraSpO2Channel.forUnit("RAW"), .unknown) + XCTAssertEqual(OuraSpO2Channel.percentageUnit, "raw") + } + + /// An unknown channel names its tag and never carries a `%`. + func testUnknownChannelLineHasNoPercentSign() { + let line = OuraSpO2Channel.firstDecodedLogLine(value: 3, unit: "DC_RAW") + XCTAssertEqual(line, + #"first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 3 (channel "DC_RAW")"#) + XCTAssertFalse(line.contains("%"), line) } func testSampleAccessorMatchesTheResolver() { diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index 7439acc8b8..73b1fd4276 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -143,10 +143,12 @@ data class OuraSpO2( * percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what it * can attribute; this makes the attribution a value rather than a string comparison. * - * OuraStreamMapping (both platforms) reads the same fact for a different purpose and deliberately keeps - * its own `unit == "raw"` ALLOW-LIST: it is a persistence gate, so an unrecognised future unit must fall - * on the "do not store" side, whereas this resolver has to name every sample it is given. Same fact, two - * dispositions — the difference is intentional. Twin of Swift `OuraSpO2Channel`. + * Both known tags are matched EXACTLY; anything else is UNKNOWN, which names its tag and never claims a + * percentage. Treating "not perfusion" as a percentage would print a case variant or a future tag's + * magnitude with a `%` on it, which is the defect this type exists to stop. OuraStreamMapping (both + * platforms) keeps its own `unit == "raw"` allow-list for the same reason from the other side: it is a + * persistence gate, so an unrecognised unit falls on the "do not store" side there and on the "do not + * call it a percentage" side here. Twin of Swift `OuraSpO2Channel`. */ enum class OuraSpO2Channel { /** @@ -157,6 +159,9 @@ enum class OuraSpO2Channel { /** 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. */ PERFUSION, + + /** A unit tag no decoder stamps today (a case variant, or a future tag). Named, never a percentage. */ + UNKNOWN, ; /** @@ -167,18 +172,26 @@ enum class OuraSpO2Channel { val logLabel: String get() = when (this) { PERCENTAGE -> "SpO2 percentage" PERFUSION -> "SpO2 raw DC perfusion (NOT a percentage)" + UNKNOWN -> "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" } companion object { + /** The unit tag 0x6F and 0x7B stamp on their samples (`OuraSpO2`'s default). */ + const val PERCENTAGE_UNIT = "raw" + /** The unit tag 0x77 stamps on its samples. */ const val PERFUSION_UNIT = "dc_raw" /** - * Resolve a sample's channel from its unit tag. Anything that is not the perfusion tag is - * treated as the percentage channel, matching how 0x6F and 0x7B both default to `"raw"`. + * Resolve a sample's channel from its unit tag. Both known tags match exactly (case-sensitive, + * like the tag); anything else is UNKNOWN rather than a guess. Twin of Swift + * `OuraSpO2Channel.forUnit`. */ - fun forUnit(unit: String): OuraSpO2Channel = - if (unit == PERFUSION_UNIT) PERFUSION else PERCENTAGE + fun forUnit(unit: String): OuraSpO2Channel = when (unit) { + PERCENTAGE_UNIT -> PERCENTAGE + PERFUSION_UNIT -> PERFUSION + else -> UNKNOWN + } /** * The strap-log body for "the first sample of this channel arrived this session", WITHOUT diff --git a/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt b/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt index a1ae50bc11..33de0c7baa 100644 --- a/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt +++ b/android/app/src/test/java/com/noop/oura/OuraSpO2ChannelOracleTest.kt @@ -16,8 +16,9 @@ import org.junit.Test * swiftc -O -I /Products/Debug -L /Products/Debug -lOuraProtocol main.swift -o oracle * ./oracle * - * where main.swift prints `value|unit|channel|OuraSpO2Channel.firstDecodedLogLine(value:unit:)` for - * each row. Re-capture and re-paste if the Swift side ever changes; do not edit these by eye. + * where main.swift prints `value|unit|channel.rawValue|OuraSpO2Channel.firstDecodedLogLine(value:unit:)` + * for each row. The last five rows are unit tags no decoder stamps: they resolve to `unknown` and carry + * no `%`. Re-capture and re-paste if the Swift side ever changes; do not edit these by eye. * * NOTE ON DIRECTION: this guards Kotlin against the Swift of the day. `OuraSpO2ChannelTests` on the * Swift side pins the same dispositions, which is what stops Swift drifting silently. @@ -36,9 +37,11 @@ class OuraSpO2ChannelOracleTest { """41132|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 41132 (channel "dc_raw")""", """65815|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 65815 (channel "dc_raw")""", """208|dc_raw|perfusion|first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 208 (channel "dc_raw")""", - """1|raw_adc|percentage|first SpO2 percentage decoded (last night) - 1 % (channel "raw_adc")""", - """2||percentage|first SpO2 percentage decoded (last night) - 2 % (channel "")""", - """3|DC_RAW|percentage|first SpO2 percentage decoded (last night) - 3 % (channel "DC_RAW")""", + """1|raw_adc|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 1 (channel "raw_adc")""", + """2||unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 2 (channel "")""", + """3|DC_RAW|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 3 (channel "DC_RAW")""", + """4|RAW|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 4 (channel "RAW")""", + """-5|dc_raw2|unknown|first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - -5 (channel "dc_raw2")""", ) @Test From 131a072da76a93de2e44ce363c2d960067ca1736 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 24 Sep 2026 16:04:01 +1200 Subject: [PATCH 3/3] chore(parity): record the SpO2 channel property as a declared platform split The Swift `var channel` added here reads as one-sided to the ledger. Its Kotlin twin is `val OuraSpO2.channel`, a TOP-LEVEL extension property with a dotted receiver, and the Kotlin property scan does not index that shape, so the pair is real but only one half is visible to the inventory. A declared "Kotlin twin:" doc reference would not help. The target is not in the Kotlin property inventory at all, so the reference would resolve to nothing and land as a dead-twin-reference, trading one finding for a worse one. A typed disposition is the instrument that fits. Everything else this branch adds pairs cleanly: property_pairs 148 -> 149 function_pairs 187 -> 189 constant_pairs 678 -> 680 unpaired_files 378 -> 376 unpaired_properties 164 -> 165 One new unpaired declaration, and it is the declared one. unpaired_functions holds at 4113 and known findings stay at 292. main is merged in rather than rebased so the two authored commits keep their original hashes. --- Tools/parity_dispositions.json | 8 ++++++++ Tools/parity_twin_map.json | 18 +++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index dad0a024bd..b1efd58add 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -72,6 +72,14 @@ "identity_sha256": "5c7b107d76b7c42ea4a0deb3bbd2bd62dc3d5f267b276af2f521502add2c73a2", "platform": "swift", "rationale": "Twin of the Kotlin UserProfile.cacheKey in android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt; the two types sit in differently named files, so the ledger cannot pair them." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-property", + "identity": "swift\u0000Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift::channel@property#1", + "identity_sha256": "ecbc737217fc7e501b46226d4560ed9f23d2bd636faeef2f5e59e0a7274821e1", + "platform": "swift", + "rationale": "Twin of the Kotlin `val OuraSpO2.channel` in android/app/src/main/java/com/noop/oura/OuraEvents.kt. Both are one-line extension properties over OuraSpO2 returning OuraSpO2Channel, added together in the same change. Kotlin declares its copy as a TOP-LEVEL extension property with a dotted receiver, which the ledger's Kotlin property scan does not index, so the Swift side reads as one-sided. The pair is real; only one half is visible to the inventory." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 8e70600893..22cb0533d1 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,16 +19,16 @@ }, "authority": { "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4475, "sha256": "d1a0cfcac67d90249035d20b4a2f188091e99ea52854467dfc6d77da50b0dc48"}, - "properties": {"count": 460, "sha256": "c7db673bedc58d2708acc8bcb3c56e287718b058f5891ffad7cce2931562dbfe"}, - "constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"}, - "file_pairs": {"count": 73, "sha256": "070b0f45b86c7dfd51ecef2ae8803909482883bf639b3622dc28d29de43364d0"}, - "function_pairs": {"count": 187, "sha256": "b5fbd974ca8c2e487b62049c2eee1cd6569a2e16a23103d907b76349e4a8423a"}, - "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, - "constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"}, - "unpaired_files": {"count": 378, "sha256": "91edbd09059e35729ba2a927016c48fc4d87b14e399f55b311d366c681392e1f"}, + "functions": {"count": 4479, "sha256": "bdb69394501d448ac150e19fa571eb18999e54ee7312d4c5517367d35926a419"}, + "properties": {"count": 463, "sha256": "7d80508acfd6418a9f1dc099d65a6890e9b663fb9b056ac2e939b0e0e42d488e"}, + "constants": {"count": 1955, "sha256": "1ec11be6a59062be6df2e194e0269df6d9a7a2720e5af121040fd8008a47b885"}, + "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, + "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, + "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, + "constant_pairs": {"count": 680, "sha256": "9f333ee3a2b11cd78ded18ba755731a26b40ed7e9d4fc1394498eb0a154437a7"}, + "unpaired_files": {"count": 376, "sha256": "654d951b8137dbb2266b83734fa1e3f1cb1648f15c1aab3d015cdef7e79deb75"}, "unpaired_functions": {"count": 4113, "sha256": "9455f235143adef089d343d45a1b43e10445953b866afb351dfa1ab9b27b4d5b"}, - "unpaired_properties": {"count": 164, "sha256": "099b516b8e64cbeab831751571b57a20d29d86b5e8b195f7e097c40c96403618"}, + "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} } }