Skip to content

refactor(data): one HR union fingerprint, not two that were free to disagree - #2570

Merged
ryanbr merged 1 commit into
mainfrom
fix/2566-single-hr-union-fingerprint
Sep 28, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/2566-single-hr-union-fingerprint

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Issue #2566. WhoopRepository carried two functions computing the same value.

hrUnionFingerprint/3 (line 1185, #2293) hrFingerprintWindowUnion/3 (line 728, #2543)
signature (activeDeviceId: String, from: Long, to: Long): String identical
ids walked rawWhoopSourceIds(activeDeviceId) identical
per id dao.countHrInWindow + dao.maxHrTsInWindow inline the same two, via hrFingerprintWindow
per-id text "$id=$count:$maxTs" "$id:$count:$maxTs"
separator , |

I added the second one in #2543 without noticing the first.

Why the encodings were free to change

Neither string is observable. StressLensCache is an in-memory object holding one key, and PhysiologicalStepCycleEngine.loadCache is a plain HashMap field of a private data class. Nothing persists either value or sends it anywhere, so both doc comments were correct that the format is free, and switching a caller from one encoding to the other costs nothing beyond a cold cache at process start, which it always has.

Why it was worth removing anyway

The redundancy was not the risk. Two witnesses of one fact that are allowed to drift is the risk, and the fact they witness is whether cached scoring work can be reused. Teach one union to walk an extra id, or to carry a third aggregate, and the other keeps answering the older question silently.

The change

hrFingerprintWindowUnion is gone. The daytime stress lens memo reads hrUnionFingerprint, which now calls hrFingerprintWindow per id rather than repeating the two DAO reads inline. One union, one encoding, one per-id read. The cycle load cache keeps the exact value it had, since the survivor is the one it already used.

Strand/Data/Repository.swift named the deleted function as its Kotlin twin, so that pointer moves to the survivor. Both sides now also say what KIND of twin this is: each compares its own value against its own previous value, neither is persisted or transmitted, and the two encode the same facts differently. There is no byte-identity contract and no oracle asserting one. Without that note the next reader could take "twin" for the byte-level pair this repository usually means by the word, and align the encodings believing they were repairing something.

Tests

Neither union had a single test, which is how a duplicate survived review in the first place. HrUnionFingerprintTest pins the property that motivated a union at all (#908): a row landing under an ALIAS id, or under the canonical id, must move the witness although the active id is untouched. A fingerprint keyed on the active id alone passes a naive equality check and fails these. The stubbed DAO throws on any method other than the two index aggregates, so a row fetch creeping into the cheap path fails too.

Mutation-verified rather than assumed, with both mutations chosen to disable the guard rather than merely perturb it:

test walk only the active id drop maxTs from each entry
walksEveryUnionIdNotJustTheActive RED RED
movesWhenARowLandsUnderTheCanonicalIdOnly RED RED
emptyWindowIsStableZeroes RED RED
movesWhenARowLandsUnderAnAliasIdOnly RED green
canonicalActiveIsNotDuplicated green RED
stableWhenNothingChanged green green

Different kill sets, so the tests discriminate distinct properties rather than all keying on one thing. Every survivor is explainable: movesWhenARowLandsUnderAnAliasIdOnly also moves the count, so dropping maxTs still registers a change; canonicalActiveIsNotDuplicated sets the active id TO the canonical one, where the union and the single id genuinely coincide; stableWhenNothingChanged asserts determinism only, which both mutants preserve.

Verification

  • Full Kotlin suite: 6528 tests, 0 failures, 0 errors, 768 classes. Confirmed actually executed rather than served up to date: every result XML carries an mtime inside the run window.
  • parity_ratchet.py --base origin/main: errors=0, no warnings.
  • Authority delta is exactly functions 4508 to 4507 and unpaired_functions 4109 to 4108, nothing else. Debt decreases by one, so no disposition is required.
  • doc_comment_lint.py: green.
  • Re-derived AFTER rebasing onto feat(oura): Experimental “Oura ring: all-day heart rate & HRV” — stand the daytime-HR hold down for the learned night, not for every screen-off #2558, not before. Deriving first would have produced a plausible-looking authority for a tree missing that PR's nine functions and two pairs, and the counts alone would not have shown it.

Note for #2567: neither of these functions can pair, because Strand/Data/Repository.swift is outside the ledger's Swift scope. That blind spot is how two Kotlin functions computing the same value both survived review, with nothing able to notice they were twins of each other.

…isagree

WhoopRepository carried two functions computing the same value. Issue #2566.

  hrUnionFingerprint/3        line 1185, from #2293
  hrFingerprintWindowUnion/3  line 728, which I added in #2543

Same signature, same rawWhoopSourceIds walk, same COUNT plus MAX per id. They
differed only in how they punctuated the result: "id=count:maxTs" joined with a
comma against "id:count:maxTs" joined with a pipe.

Neither encoding is observable. StressLensCache is an in-memory object holding a
single key, and PhysiologicalStepCycleEngine's loadCache is a plain HashMap
field of a private data class. Nothing persists either string or sends it
anywhere, so both doc comments were right that the format is free to change.

The redundancy was not the risk. Two witnesses of one fact that are allowed to
drift is the risk, and the fact they witness is whether cached scoring work can
be reused. Teach one union to walk an extra id or carry a third aggregate and
the other keeps answering the older question, silently.

So: hrFingerprintWindowUnion is gone, the daytime stress lens memo reads
hrUnionFingerprint, and hrUnionFingerprint now calls hrFingerprintWindow for
each id rather than repeating the two DAO reads inline. One union, one encoding,
one per-id read. The cycle load cache keeps the exact value it had.

Strand/Data/Repository.swift referenced the deleted function as its Kotlin twin,
so that pointer moves to the survivor. Both sides now also say what kind of twin
this is: each compares its own value against its own previous value, neither is
persisted or transmitted, and the two encode the same facts differently. There
is no byte-identity contract here and no oracle asserting one. Without that note
the next reader could take "twin" for the usual byte-level pair this repository
means by the word and align the encodings believing they were repairing
something.

Neither union had a single test, which is how a duplicate survived review.
HrUnionFingerprintTest pins the property that motivated the union at all: a row
landing under an alias id or under the canonical id must move the witness even
when the active id is untouched (#908). A fingerprint keyed on the active id
alone passes a naive equality check and fails these. The stubbed DAO throws on
any method other than the two index aggregates, so a row fetch creeping into the
cheap path fails too.

Mutation-verified rather than assumed. Replacing the union walk with the bare
active id reddens 4 of 6; dropping maxTs from each entry reddens a different 4
of 6. The survivors are explainable in both cases: stableWhenNothingChanged only
asserts determinism, and canonicalActiveIsNotDuplicated sets the active id to
the canonical one, where the union and the single id genuinely coincide.
@ryanbr
ryanbr merged commit 4e8c041 into main Sep 28, 2026
7 checks passed
@ryanbr
ryanbr deleted the fix/2566-single-hr-union-fingerprint branch September 28, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant