From 9ca2962c45c3c9c91933d07a96859e02486b2471 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 15 Sep 2026 20:53:13 +1200 Subject: [PATCH 1/3] analytics(android): Kotlin LiftMetrics twin of the Swift engine (#2099) Lift Log shipped its analytics on Swift only. This adds the Kotlin twin so both sides compute identically, with an oracle test whose expected values are the Swift build's own stdout pasted verbatim rather than hand-written. LiftMuscle.kt carries the 20 muscle tokens in Swift's order and spelling. That matters because the encoded list crosses a .noopbak boundary, so a reordering would silently remap stored data. Reciprocal twin claims on both sides let the parity ledger pair the declarations. Measured against a clean worktree at the same base, total drift goes from 62 to 55: twelve Swift-side requirements retire, five arrive. The five that arrive fold into the #2229 authority decision: * fromRaw, Kotlin-only by design, since Swift gets the same thing free from RawRepresentable.init(rawValue:) * four test-only-callsite findings, because Android has Room lift entities and an importer but no Lift Log screen, so nothing in the app calls the engine yet That last point is deliberate and worth stating plainly: the engine is ported ahead of its consumer. It is verified against Swift, and it is not yet wired to any Android surface. --- .../Sources/StrandAnalytics/LiftMetrics.swift | 12 + .../java/com/noop/analytics/LiftMetrics.kt | 268 ++++++++++++++++++ .../src/main/java/com/noop/data/LiftMuscle.kt | 106 +++++++ .../analytics/LiftMetricsParityOracleTest.kt | 162 +++++++++++ 4 files changed, 548 insertions(+) create mode 100644 android/app/src/main/java/com/noop/analytics/LiftMetrics.kt create mode 100644 android/app/src/main/java/com/noop/data/LiftMuscle.kt create mode 100644 android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift index 2d7fb96855..dca9444343 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -38,6 +38,8 @@ public enum LiftMetrics { /// /// Good for: tracking progression WITHIN one exercise across weeks. Not comparable between /// exercises — 100 kg of leg press is not 100 kg of squat — and not comparable between people. + /// + /// The Kotlin twin is `LiftMetrics.volumeLoadKg`. public static func volumeLoadKg(_ sets: [LiftSetRow]) -> Double? { let total = sets.reduce(into: 0.0) { sum, s in guard !s.isWarmup, let w = s.weightKg, let r = s.reps, w > 0, r > 0 else { return } @@ -55,6 +57,8 @@ public enum LiftMetrics { /// the app that puts a leg day and a run on one comparable scale. /// /// Nil when the session was not rated — a skipped rating must never be read as an effortless 0. + /// + /// The Kotlin twin is `LiftMetrics.sessionLoad`. public static func sessionLoad(sessionRpe: Double?, durationSec: Int) -> Double? { guard let rpe = sessionRpe, rpe > 0, durationSec > 0 else { return nil } return rpe * (Double(durationSec) / 60.0) @@ -73,6 +77,8 @@ public enum LiftMetrics { /// /// A single rep returns the weight itself — the formula's own +3.3% at one rep is an artefact of /// the fit, not a claim that a single you just completed was really 3% heavier. + /// + /// The Kotlin twin is `LiftMetrics.estimatedOneRepMaxKg`. public static func estimatedOneRepMaxKg(weightKg: Double?, reps: Int?) -> Double? { guard let w = weightKg, let r = reps, w > 0, r > 0, r <= oneRepMaxRepCeiling else { return nil } guard r > 1 else { return w } @@ -108,6 +114,8 @@ public enum LiftMetrics { /// One summary per exercise, in the order the exercises were first performed — which is the /// order they were done in, not alphabetical, because that is how a session reads back. + /// + /// The Kotlin twin is `LiftMetrics.perExercise`. public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] { var order: [String] = [] var grouped: [String: [LiftSetRow]] = [:] @@ -170,6 +178,8 @@ public enum LiftMetrics { /// what makes a set count biologically. Doing that would compare a smaller number against /// reference doses derived from UNFILTERED working-set counts — quietly changing the scale. /// `unratedSets` is surfaced so a mean computed from three of twelve sets is visibly thin. + /// + /// The Kotlin twin is `LiftMetrics.rpeProfile`. public static func rpeProfile(_ sets: [LiftSetRow], threshold: Double = hardSetRpeThreshold) -> RpeProfile { let working = sets.filter { !$0.isWarmup } @@ -209,6 +219,8 @@ public enum LiftMetrics { /// /// Warm-ups are excluded; nothing else is. An unclassified exercise (nil primary) contributes to /// volume and session load but claims no muscle it was never assigned. + /// + /// The Kotlin twin is `LiftMetrics.muscleCounts`. public static func muscleCounts(_ sets: [LiftSetRow]) -> MuscleCounts { var fractional: [LiftMuscle: Double] = [:] var direct: [LiftMuscle: Int] = [:] diff --git a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt new file mode 100644 index 0000000000..72959eceff --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt @@ -0,0 +1,268 @@ +package com.noop.analytics + +import com.noop.data.LiftMuscle + +/** + * Training metrics for the Lift Log. + * + * Kotlin twin of `StrandAnalytics.LiftMetrics` (#2099), so both platforms report the same numbers + * from the same sets. Pinned by `LiftMetricsParityOracleTest`, whose expected values are the Swift + * build's own stdout. + * + * Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the + * design constraint: NOOP shows a few honest numbers rather than one invented score. + * + * PURE. No store, no clock, no UI — rows in, numbers out. + * + * WHAT IS DELIBERATELY ABSENT, and must stay absent: + * + * - Anything feeding `workout.strain` or daily Effort. NOOP's strain is HR-measured (Karvonen + * %HRR into Edwards TRIMP). There is no validated public path from typed sets/reps/weight to a + * cardiovascular-strain equivalent, and inventing one is the case CLAUDE.md warns about after + * the withdrawn PPG-to-HR estimate (#194). + * - Per-exercise muscle weightings ("bench press = 0.7 triceps"). No published table exists to + * take them from. The direct/indirect split is the resolution the evidence supports. + * - Acute:chronic workload ratios or any injury-risk warning. The construct's validity is disputed, + * and a health warning from a non-medical app is either ignored or believed, both bad. + */ +object LiftMetrics { + + /** The fields of a logged set this file needs. Mirrors Swift `LiftSetRow`'s countable surface. */ + data class Row( + val ord: Int, + val exercise: String, + val isWarmup: Boolean, + val weightKg: Double? = null, + val reps: Int? = null, + val rpe: Double? = null, + val primaryMuscle: LiftMuscle? = null, + val secondaryMuscles: List = emptyList(), + ) + + // MARK: - Volume load (tonnage) + + /** + * Sum of (weight x reps) over WORKING sets, in kilograms. Null when nothing countable was logged. + * + * Warm-ups are excluded because the literature counts working sets, and a warm-up double counted + * as volume would flatter every session. A set missing either weight or reps contributes nothing + * rather than a guess. + * The Swift twin is `LiftMetrics.volumeLoadKg`. + */ + fun volumeLoadKg(sets: List): Double? { + var total = 0.0 + for (s in sets) { + if (s.isWarmup) continue + val w = s.weightKg ?: continue + val r = s.reps ?: continue + if (w <= 0 || r <= 0) continue + total += w * r.toDouble() + } + return if (total > 0) total else null + } + + // MARK: - Session load (Foster sRPE-TL) + + /** + * Session RPE x duration in minutes. Foster's session-RPE training load. + * + * It earns its place next to volume because it is validated across BOTH resistance and endurance + * training, making it the only figure here that puts a leg day and a run on one scale. + * + * Null when the session was not rated: a skipped rating must never read as an effortless 0. + * The Swift twin is `LiftMetrics.sessionLoad`. + */ + fun sessionLoad(sessionRpe: Double?, durationSec: Int): Double? { + val rpe = sessionRpe ?: return null + if (rpe <= 0 || durationSec <= 0) return null + return rpe * (durationSec.toDouble() / 60.0) + } + + // MARK: - Estimated one-rep max (Epley) + + /** + * The rep ceiling above which a 1RM estimate stops being worth showing. Every 1RM formula is a + * straight-line fit to a curved relationship and the error grows with reps; twelve is the + * conventional bound where the formulas are least unreliable. + */ + const val oneRepMaxRepCeiling: Int = 12 + + /** + * Epley: `w * (1 + reps/30)`. Null for a set that cannot support an estimate. + * + * A single rep returns the weight itself. The formula's own +3.3% at one rep is an artefact of + * the fit, not a claim that a single you just completed was really 3% heavier. + * The Swift twin is `LiftMetrics.estimatedOneRepMaxKg`. + */ + fun estimatedOneRepMaxKg(weightKg: Double?, reps: Int?): Double? { + val w = weightKg ?: return null + val r = reps ?: return null + if (w <= 0 || r <= 0 || r > oneRepMaxRepCeiling) return null + if (r <= 1) return w + return w * (1.0 + r.toDouble() / 30.0) + } + + // MARK: - Per-exercise summary + + data class ExerciseSummary( + val exercise: String, + /** Working sets only: the tally the dose-response literature is built on. */ + val workingSets: Int, + val warmupSets: Int, + val volumeKg: Double?, + /** + * The session's best set for this exercise, ranked by ESTIMATED 1RM rather than raw weight: + * 90 kg x 10 is a better set than 100 kg x 5, and ranking by weight alone would hide that. + * Falls back to the heaviest set when no set supports an estimate. + */ + val bestWeightKg: Double?, + val bestReps: Int?, + val bestEstimatedOneRepMaxKg: Double?, + ) + + /** + * One summary per exercise, in the order the exercises were first performed, because that is how + * a session reads back. + * The Swift twin is `LiftMetrics.perExercise`. + */ + fun perExercise(sets: List): List { + val order = ArrayList() + val grouped = LinkedHashMap>() + for (s in sets.sortedBy { it.ord }) { + if (grouped[s.exercise] == null) { + order.add(s.exercise) + grouped[s.exercise] = ArrayList() + } + grouped.getValue(s.exercise).add(s) + } + return order.map { name -> + val rows = grouped[name] ?: emptyList() + val working = rows.filter { !it.isWarmup } + + // Rank by estimated 1RM where possible, otherwise raw weight, so an exercise logged only + // at high reps still reports a best set rather than nothing. `maxWithOrNull` keeps the + // FIRST of equals, matching Swift's `max(by:)`. + val best = working.maxWithOrNull { a, b -> + val ea = estimatedOneRepMaxKg(a.weightKg, a.reps) + val eb = estimatedOneRepMaxKg(b.weightKg, b.reps) + when { + ea != null && eb != null -> ea.compareTo(eb) + ea != null -> 1 // a ranks above b + eb != null -> -1 // b ranks above a + else -> (a.weightKg ?: 0.0).compareTo(b.weightKg ?: 0.0) + } + } + ExerciseSummary( + exercise = name, + workingSets = working.size, + warmupSets = rows.size - working.size, + volumeKg = volumeLoadKg(rows), + bestWeightKg = best?.weightKg, + bestReps = best?.reps, + bestEstimatedOneRepMaxKg = estimatedOneRepMaxKg(best?.weightKg, best?.reps), + ) + } + } + + // MARK: - RPE profile + + data class RpeProfile( + val mean: Double?, + val ratedSets: Int, + val unratedSets: Int, + val setsAtOrAboveThreshold: Int, + val threshold: Double, + ) + + /** The default "this set was close to failure" line. Informational only. */ + const val hardSetRpeThreshold: Double = 8.0 + + /** + * How close to failure the working sets were. + * + * Reported SEPARATELY from the set counts and never as a filter on them. The tempting move is to + * count only sets at RPE >= 7 toward a muscle's weekly total, since proximity to failure is what + * makes a set count biologically. Doing that would compare a smaller number against reference + * doses derived from UNFILTERED working-set counts, quietly changing the scale. `unratedSets` is + * surfaced so a mean computed from three of twelve sets is visibly thin. + * The Swift twin is `LiftMetrics.rpeProfile`. + */ + fun rpeProfile(sets: List, threshold: Double = hardSetRpeThreshold): RpeProfile { + val working = sets.filter { !it.isWarmup } + val rated = working.mapNotNull { it.rpe } + val mean = if (rated.isEmpty()) null else rated.sum() / rated.size.toDouble() + return RpeProfile( + mean = mean, + ratedSets = rated.size, + unratedSets = working.size - rated.size, + setsAtOrAboveThreshold = rated.count { it >= threshold }, + threshold = threshold, + ) + } + + // MARK: - Sets per muscle + + data class MuscleCounts( + /** direct x 1.0 + indirect x 0.5, the published fractional method. */ + val fractional: Map, + val direct: Map, + val indirect: Map, + ) + + /** + * Fractional set counts per muscle over the given sets. + * + * The 0.5 for an indirect set is NOT a house convention: the 2025 Sports Medicine dose-response + * meta-regression compared counting a secondary mover's set as 1.0 ("total"), 0.5 ("fractional") + * and 0.0 ("direct"), found the evidence strongest for fractional, and used it in its primary + * models. The reference doses in [ReferenceDose] were derived under that same operationalisation, + * so the credit and the doses have to move together or the comparison stops meaning anything. + * + * Warm-ups are excluded; nothing else is. An unclassified exercise (null primary) contributes to + * volume and session load but claims no muscle it was never assigned. + * The Swift twin is `LiftMetrics.muscleCounts`. + */ + fun muscleCounts(sets: List): MuscleCounts { + val fractional = LinkedHashMap() + val direct = LinkedHashMap() + val indirect = LinkedHashMap() + for (s in sets) { + if (s.isWarmup) continue + s.primaryMuscle?.let { p -> + direct[p] = (direct[p] ?: 0) + 1 + fractional[p] = (fractional[p] ?: 0.0) + LiftMuscle.directSetCredit + } + for (m in s.secondaryMuscles) { + if (m == s.primaryMuscle) continue + indirect[m] = (indirect[m] ?: 0) + 1 + fractional[m] = (fractional[m] ?: 0.0) + LiftMuscle.indirectSetCredit + } + } + return MuscleCounts(fractional = fractional, direct = direct, indirect = indirect) + } + + // MARK: - The reference band + + /** + * Weekly fractional sets per muscle, from the same dose-response meta-regression the 0.5 credit + * comes from. + * + * PRESENTED AS A BAND WITH ITS SOURCE NAMED, NEVER AS A PERSONAL PRESCRIPTION. NOOP is not a + * medical device and does not tell anyone what their body needs; it says what the research + * associates with growth and leaves the conclusion to the reader. + */ + object ReferenceDose { + /** Below roughly this, hypertrophy is not reliably detectable. */ + const val hypertrophyMinimumSetsPerWeek: Double = 4.0 + + /** Strength keeps improving from a single weekly set. */ + const val strengthMinimumSetsPerWeek: Double = 1.0 + + /** + * Beyond roughly this, added volume stops reliably beating the smallest detectable effect + * FOR STRENGTH. Hypertrophy has no identified ceiling: gains continue with strongly + * diminishing returns, and the uncertainty widens as volume rises. + */ + const val strengthPlateauSetsPerWeek: Double = 4.0 + } +} diff --git a/android/app/src/main/java/com/noop/data/LiftMuscle.kt b/android/app/src/main/java/com/noop/data/LiftMuscle.kt new file mode 100644 index 0000000000..fd2cbc7aaf --- /dev/null +++ b/android/app/src/main/java/com/noop/data/LiftMuscle.kt @@ -0,0 +1,106 @@ +package com.noop.data + +/** + * The muscle vocabulary a logged set is classified under. + * + * Kotlin mirror of the macOS/iOS source of truth + * Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift + * + * THE TOKENS ARE A STORED-DATA CONTRACT. `name` is what lands in the `primaryMuscle` column and in + * the comma-joined `secondaryMuscles` column, and the same tokens cross the `.noopbak` boundary to + * an Apple install. Never rename or remove one; adding is safe because [decodeList] skips what it + * does not recognise. + */ +enum class LiftMuscle { + // Push + chest, + frontDelts, + sideDelts, + rearDelts, + triceps, + + // Pull + lats, + upperBack, + traps, + biceps, + forearms, + + // Legs + quads, + hamstrings, + glutes, + adductors, + abductors, + calves, + + // Trunk + abs, + obliques, + lowerBack, + neck, + ; + + /** + * Coarse section, used only to group the picker. Not stored, not counted — purely presentation + * scaffolding, so changing it is free. + */ + enum class Region { push, pull, legs, trunk } + + val region: Region + get() = when (this) { + chest, frontDelts, sideDelts, rearDelts, triceps -> Region.push + lats, upperBack, traps, biceps, forearms -> Region.pull + quads, hamstrings, glutes, adductors, abductors, calves -> Region.legs + abs, obliques, lowerBack, neck -> Region.trunk + } + + companion object { + /** + * A set credits its PRIMARY muscle in full and each SECONDARY at a half. + * + * The 0.5 is not a house convention: the dose-response meta-regression the reference doses + * come from compared 1.0, 0.5 and 0.0 for a secondary mover and found the evidence + * strongest for fractional, which its primary models use. Change this and the reference + * doses stop meaning what they claim. + */ + const val directSetCredit: Double = 1.0 + const val indirectSetCredit: Double = 0.5 + + /** + * Raw token to case, or null when the token is not one this build knows. + * + * Kotlin-only by design: Swift gets this free from `RawRepresentable.init(rawValue:)`, so + * there is no Swift declaration to pair with rather than a missing one. + */ + fun fromRaw(raw: String?): LiftMuscle? = + raw?.let { token -> entries.firstOrNull { it.name == token } } + + /** + * Encode a secondary list for storage. Returns null for an empty list so the column stays + * NULL rather than holding an empty string: two spellings of "none" is a bug waiting. + * Duplicates and the primary are stripped so one set can never be counted twice for one + * muscle, and the user's ordering is preserved. + * + * The Swift twin is `LiftMuscle.encodeList`. + */ + fun encodeList(muscles: List, primary: LiftMuscle? = null): String? { + val seen = LinkedHashSet() + if (primary != null) seen.add(primary) + val kept = ArrayList() + for (m in muscles) if (seen.add(m)) kept.add(m) + return if (kept.isEmpty()) null else kept.joinToString(",") { it.name } + } + + /** + * Decode a stored secondary list. Unknown tokens are skipped rather than failing the read: + * a database written by a newer build must stay readable by an older one. + * + * The Swift twin is `LiftMuscle.decodeList`. + */ + fun decodeList(stored: String?): List { + if (stored.isNullOrEmpty()) return emptyList() + return stored.split(",").mapNotNull { fromRaw(it) } + } + } +} diff --git a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt new file mode 100644 index 0000000000..ad11480603 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt @@ -0,0 +1,162 @@ +package com.noop.analytics + +import com.noop.data.LiftMuscle +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins [LiftMetrics] against the Swift source of truth by ORACLE, not by eye. + * + * The expected block below is the verbatim stdout of the Swift implementation compiled standalone + * (`swiftc -O twin.swift main.swift -o oracle && ./oracle`) from + * `Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift`. Reading the two files side + * by side does not catch what this does: the cases below were chosen for the edges where a port + * silently diverges. + * + * - Bench ranks a 1RM TIE between 90x10 and 120x1, both estimating 120.0. Swift's `max(by:)` keeps + * the FIRST of equals and Kotlin's `maxWithOrNull` does the same; a port using `maxByOrNull` on + * the estimate would return the last and report 120 kg x 1 instead of 90 kg x 10. + * - Row has no set under the rep ceiling, so the comparator must fall through to raw weight while + * still preferring any set that DOES support an estimate. + * - Curl has one set with no weight and one with no reps, so the fallback compares 0.0 against 20. + * - Set 2 lists `chest` as both primary and secondary; it must be counted once. + * - Set 4 is 13 reps, one past the ceiling, so it estimates nil rather than a number. + * + * The oracle only guards this direction. `LiftMetricsTests` on the Swift side is what stops Swift + * drifting away from Kotlin. + */ +class LiftMetricsParityOracleTest { + + private fun row( + ord: Int, ex: String, w: Double?, r: Int?, rpe: Double?, warm: Boolean, + p: LiftMuscle?, sec: List, + ) = LiftMetrics.Row( + ord = ord, exercise = ex, isWarmup = warm, weightKg = w, reps = r, rpe = rpe, + primaryMuscle = p, secondaryMuscles = sec, + ) + + private val sets = listOf( + row(0, "Bench", 60.0, 12, 7.0, true, LiftMuscle.chest, listOf(LiftMuscle.triceps, LiftMuscle.frontDelts)), + row(1, "Bench", 100.0, 5, 8.0, false, LiftMuscle.chest, listOf(LiftMuscle.triceps, LiftMuscle.frontDelts)), + row(2, "Bench", 90.0, 10, 9.5, false, LiftMuscle.chest, listOf(LiftMuscle.triceps, LiftMuscle.chest)), + row(3, "Bench", 120.0, 1, null, false, LiftMuscle.chest, emptyList()), + row(4, "Row", 80.0, 13, 6.0, false, LiftMuscle.lats, listOf(LiftMuscle.biceps)), + row(5, "Row", 70.0, 8, null, false, LiftMuscle.lats, listOf(LiftMuscle.biceps)), + row(6, "Curl", null, 10, 8.0, false, LiftMuscle.biceps, emptyList()), + row(7, "Curl", 20.0, null, 8.0, false, LiftMuscle.biceps, emptyList()), + row(8, "Plank", 0.0, 0, null, false, null, emptyList()), + ) + + private fun f(d: Double?) = if (d == null) "nil" else String.format("%.6f", d) + private fun i(v: Int?) = v?.toString() ?: "nil" + + /** Verbatim stdout of the Swift build. Do not hand-edit: regenerate from the oracle. */ + private val expected = """ + == volumeLoadKg == + 3120.000000 + nil + nil + == sessionLoad == + 480.000000 + nil + nil + 9.000000 + nil + == estimatedOneRepMaxKg == + 100.000000 + 116.666667 + 120.000000 + 112.000000 + nil + nil + nil + nil + nil + == perExercise == + Bench|3|1|1520.000000|90.000000|10|120.000000 + Row|2|0|1600.000000|70.000000|8|88.666667 + Curl|2|0|nil|20.000000|nil|nil + Plank|1|0|nil|0.000000|0|nil + == rpeProfile == + 7.900000|5|3|4|8.000000 + 7.900000|5|3|4|7.000000 + nil|0|0|0|8.000000 + == muscleCounts == + chest|3.000000|3|nil + frontDelts|0.500000|nil|1 + triceps|1.000000|nil|2 + lats|2.000000|2|nil + biceps|3.000000|2|2 + == constants == + 12 + 8.000000 + 4.000000 + 1.000000 + 4.000000 + """.trimIndent() + + private fun render(): String { + val out = StringBuilder() + out.appendLine("== volumeLoadKg ==") + out.appendLine(f(LiftMetrics.volumeLoadKg(sets))) + out.appendLine(f(LiftMetrics.volumeLoadKg(emptyList()))) + out.appendLine(f(LiftMetrics.volumeLoadKg(listOf(sets[0])))) + + out.appendLine("== sessionLoad ==") + for ((r, d) in listOf(8.0 to 3600, 0.0 to 3600, 7.5 to 0, 6.0 to 90)) { + out.appendLine(f(LiftMetrics.sessionLoad(r, d))) + } + out.appendLine(f(LiftMetrics.sessionLoad(null, 3600))) + + out.appendLine("== estimatedOneRepMaxKg ==") + for ((w, r) in listOf( + 100.0 to 1, 100.0 to 5, 90.0 to 10, 80.0 to 12, 80.0 to 13, 0.0 to 5, 100.0 to 0, + )) { + out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(w, r))) + } + out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(null, 5))) + out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(100.0, null))) + + out.appendLine("== perExercise ==") + for (s in LiftMetrics.perExercise(sets)) { + out.appendLine( + "${s.exercise}|${s.workingSets}|${s.warmupSets}|${f(s.volumeKg)}|" + + "${f(s.bestWeightKg)}|${i(s.bestReps)}|${f(s.bestEstimatedOneRepMaxKg)}" + ) + } + + out.appendLine("== rpeProfile ==") + for (p in listOf( + LiftMetrics.rpeProfile(sets), + LiftMetrics.rpeProfile(sets, 7.0), + LiftMetrics.rpeProfile(emptyList()), + )) { + out.appendLine( + "${f(p.mean)}|${p.ratedSets}|${p.unratedSets}|${p.setsAtOrAboveThreshold}|${f(p.threshold)}" + ) + } + + out.appendLine("== muscleCounts ==") + val mc = LiftMetrics.muscleCounts(sets) + for (m in LiftMuscle.entries) { + val fr = mc.fractional[m] + val di = mc.direct[m] + val ind = mc.indirect[m] + if (fr == null && di == null && ind == null) continue + out.appendLine("${m.name}|${f(fr)}|${i(di)}|${i(ind)}") + } + + out.appendLine("== constants ==") + out.appendLine(LiftMetrics.oneRepMaxRepCeiling.toString()) + out.appendLine(f(LiftMetrics.hardSetRpeThreshold)) + out.appendLine(f(LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek)) + out.appendLine(f(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek)) + out.appendLine(f(LiftMetrics.ReferenceDose.strengthPlateauSetsPerWeek)) + return out.toString().trimEnd() + } + + @Test + fun kotlinMatchesTheSwiftOracleExactly() { + assertEquals(expected, render()) + } +} From 595402d9924c39c897ec81870edf574595dc02ca Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 15 Sep 2026 21:04:23 +1200 Subject: [PATCH 2/3] analytics(android): oracle the LiftMuscle stored-list codec Re-review of the twin found `encodeList` and `decodeList` had no Kotlin test at all. They are the one part of the Lift Log that is a stored-data contract: the output lands in the `secondaryMuscles` column and crosses the .noopbak boundary to an Apple install, so a divergence is silent corruption on restore rather than a wrong number on a screen. Both also now claim a twin, which makes the ledger treat them as guarded when nothing guarded them. `LiftMuscleParityOracleTest` pins them against Swift by the same oracle method, including the cases where the two languages genuinely differ: * Swift's split(separator:) omits empty subsequences and Kotlin's split(",") keeps them, so "chest,,biceps", ",", "chest," and ",chest" pin that the two agree anyway, an empty token resolving to no case on either side. That was reasoned to be safe when the twin was written; now it is measured. * decoding does not deduplicate on either side while encoding does, so "chest,chest" pins the asymmetry as deliberate * tokens match exactly: " chest" is not trimmed, "CHEST" is not case-folded Mutation-tested rather than assumed: adding .distinct() to decodeList turns it red, and dropping the primary exclusion in encodeList turns it red. Two more from the same pass: * both oracle tests formatted doubles with the default locale, so they would fail for anyone running the suite on a comma-decimal machine while passing in CI. Pinned to Locale.ROOT. * perExercise assumes `ord` is unique. It is, because ord is 0-based within a session and the only caller passes one session. Recorded in the source because if that changes the platforms disagree on ties, Kotlin's sortedBy being stable where Swift's sorted(by:) is not. --- .../java/com/noop/analytics/LiftMetrics.kt | 6 ++ .../analytics/LiftMetricsParityOracleTest.kt | 3 +- .../noop/data/LiftMuscleParityOracleTest.kt | 102 ++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/java/com/noop/data/LiftMuscleParityOracleTest.kt diff --git a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt index 72959eceff..90d2c83d97 100644 --- a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt +++ b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt @@ -123,6 +123,12 @@ object LiftMetrics { /** * One summary per exercise, in the order the exercises were first performed, because that is how * a session reads back. + * + * Assumes `ord` is unique across the rows handed in, which holds because it is assigned 0-based + * within one session and the only caller passes one session's sets. If that ever stops being + * true the two platforms can disagree on ties: `sortedBy` is stable, Swift's `sorted(by:)` is + * not, so Swift would also stop agreeing with itself. Fix it by making `ord` unique rather than + * by matching an unspecified order here. * The Swift twin is `LiftMetrics.perExercise`. */ fun perExercise(sets: List): List { diff --git a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt index ad11480603..f7f5ca6a17 100644 --- a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt +++ b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt @@ -1,6 +1,7 @@ package com.noop.analytics import com.noop.data.LiftMuscle +import java.util.Locale import org.junit.Assert.assertEquals import org.junit.Test @@ -47,7 +48,7 @@ class LiftMetricsParityOracleTest { row(8, "Plank", 0.0, 0, null, false, null, emptyList()), ) - private fun f(d: Double?) = if (d == null) "nil" else String.format("%.6f", d) + private fun f(d: Double?) = if (d == null) "nil" else String.format(Locale.ROOT, "%.6f", d) private fun i(v: Int?) = v?.toString() ?: "nil" /** Verbatim stdout of the Swift build. Do not hand-edit: regenerate from the oracle. */ diff --git a/android/app/src/test/java/com/noop/data/LiftMuscleParityOracleTest.kt b/android/app/src/test/java/com/noop/data/LiftMuscleParityOracleTest.kt new file mode 100644 index 0000000000..3c4f70f7ec --- /dev/null +++ b/android/app/src/test/java/com/noop/data/LiftMuscleParityOracleTest.kt @@ -0,0 +1,102 @@ +package com.noop.data + +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins [LiftMuscle]'s stored-list codec against the Swift source of truth by ORACLE. + * + * The expected block is the verbatim stdout of `Packages/WhoopStore/Sources/WhoopStore/ + * LiftMuscle.swift` compiled standalone (`swiftc -O twin.swift main.swift -o oracle && ./oracle`). + * + * This codec is the one piece of the Lift Log that is a STORED-DATA CONTRACT: its output lands in + * the `secondaryMuscles` column and crosses the `.noopbak` boundary to an Apple install, so a + * divergence here is silent data corruption on restore rather than a wrong number on a screen. + * + * The cases are chosen where the two languages' string handling genuinely differs: + * + * - Swift's `split(separator:)` omits empty subsequences by default; Kotlin's `split(",")` KEEPS + * them. `"chest,,biceps"`, `","`, `"chest,"` and `",chest"` all pin that the two agree anyway, + * because an empty token resolves to no case on either side. This was reasoned to be safe when + * the twin was written. Reasoning is not evidence, so it is pinned. + * - Decoding does NOT deduplicate: `"chest,chest"` yields two entries on both sides. Only + * `encodeList` strips duplicates, and pinning both halves keeps that asymmetry deliberate. + * - Tokens are matched exactly: `" chest"` is not trimmed and `"CHEST"` is not case-folded, so + * neither resolves. + */ +class LiftMuscleParityOracleTest { + + private fun s(v: String?) = v ?: "nil" + private fun l(v: List) = if (v.isEmpty()) "[]" else v.joinToString(",") { it.name } + + /** Verbatim stdout of the Swift build. Do not hand-edit: regenerate from the oracle. */ + private val expected = """ + == encodeList == + nil + triceps,frontDelts + triceps + nil + triceps,biceps + triceps,biceps + biceps,triceps + chest + == decodeList == + [] + [] + chest + chest,biceps + chest,biceps + [] + chest + chest + chest,biceps + [] + [] + chest,chest + == roundTrip == + triceps,frontDelts + triceps,frontDelts + == credits == + 1.000000 + 0.500000 + """.trimIndent() + + private fun render(): String { + val out = StringBuilder() + out.appendLine("== encodeList ==") + out.appendLine(s(LiftMuscle.encodeList(emptyList()))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.triceps, LiftMuscle.frontDelts)))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.chest, LiftMuscle.triceps), LiftMuscle.chest))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.chest), LiftMuscle.chest))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.triceps, LiftMuscle.triceps, LiftMuscle.biceps)))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.triceps, LiftMuscle.biceps), LiftMuscle.lats))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.biceps, LiftMuscle.triceps)))) + out.appendLine(s(LiftMuscle.encodeList(listOf(LiftMuscle.chest, LiftMuscle.chest), null))) + + out.appendLine("== decodeList ==") + for (stored in listOf( + null, "", "chest", "chest,biceps", "chest,,biceps", ",", "chest,", ",chest", + "chest,nosuchmuscle,biceps", " chest", "CHEST", "chest,chest", + )) { + out.appendLine(l(LiftMuscle.decodeList(stored))) + } + + out.appendLine("== roundTrip ==") + val rt = LiftMuscle.encodeList( + listOf(LiftMuscle.triceps, LiftMuscle.frontDelts, LiftMuscle.triceps), LiftMuscle.chest, + ) + out.appendLine(s(rt)) + out.appendLine(l(LiftMuscle.decodeList(rt))) + + out.appendLine("== credits ==") + out.appendLine(String.format(Locale.ROOT, "%.6f", LiftMuscle.directSetCredit)) + out.appendLine(String.format(Locale.ROOT, "%.6f", LiftMuscle.indirectSetCredit)) + return out.toString().trimEnd() + } + + @Test + fun kotlinMatchesTheSwiftOracleExactly() { + assertEquals(expected, render()) + } +} From 8acce9233c712e17d68450883e8f91bfab10298a Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 15 Sep 2026 21:13:27 +1200 Subject: [PATCH 3/3] analytics(android): normalise a Row's secondaries the way Swift's row does Second re-review pass found a real parity bug, not a style point. Swift's `LiftSetRow.init` normalises the secondary list at construction: self.secondaryMuscles = LiftMuscle.decodeList( LiftMuscle.encodeList(secondaryMuscles, excluding: primaryMuscle)) so `LiftMetrics` on that side never sees a repeated muscle. `LiftMetrics.Row` carried the list through raw, and `muscleCounts` strips only the primary, never duplicates. A set naming a muscle twice was therefore counted twice for it. Measured on both sides rather than argued. For one working set with primary `chest` and secondaries `[triceps, triceps, chest]`: | | fractional triceps | indirect sets | |---|---|---| | Swift | 0.500000 | 1 | | Kotlin, before | 1.000000 | 2 | Double fractional credit for that muscle, against reference doses derived under the fractional method, so the comparison the doses exist for stops holding. `Row` now applies the same decode-of-encode at construction, which is where Swift applies it, so any later reader of `secondaryMuscles` sees the same list Swift would rather than only `muscleCounts` being patched. It is no longer a `data class`: the generated `copy()` bypasses the constructor and would hand back an unnormalised row. The oracle was blind to this by construction. Its Swift stub declared `LiftSetRow` as a plain memberwise struct, so it modelled `LiftMetrics` in isolation rather than how it is actually fed. The stub now carries the real init's normalisation line verbatim, the fixture gains a `Dip` row naming triceps three times plus the primary, and a `normalisedSecondaries` block pins the per-row list itself so a regression names the cause rather than a stray sum. Mutation-tested: dropping the normalisation turns the oracle red. --- .../java/com/noop/analytics/LiftMetrics.kt | 27 +++++++++++-- .../analytics/LiftMetricsParityOracleTest.kt | 39 ++++++++++++++++--- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt index 90d2c83d97..7a9b58d187 100644 --- a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt +++ b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt @@ -27,8 +27,24 @@ import com.noop.data.LiftMuscle */ object LiftMetrics { - /** The fields of a logged set this file needs. Mirrors Swift `LiftSetRow`'s countable surface. */ - data class Row( + /** + * The fields of a logged set this file needs. Mirrors Swift `LiftSetRow`'s countable surface, + * INCLUDING its invariant: the secondary list is normalised at construction, so it never + * repeats a muscle and never contains the primary. + * + * Swift gets that from `LiftSetRow.init`, which runs the secondaries it is handed through + * `decodeList(encodeList(_:excluding:))` before storing them. The normalisation has to live + * here rather than in [muscleCounts] because it is the row that carries the guarantee on the + * other side, and any future reader of `secondaryMuscles` should see the same list Swift would. + * + * Measured rather than assumed: for a set with `[triceps, triceps, chest]` secondary and + * `chest` primary, Swift credits triceps 0.5 across one indirect set. Before this step Kotlin + * credited it 1.0 across two, double-counting the muscle. + * + * Not a `data class`: the generated `copy()` would rebuild the object through the constructor + * it bypasses, reintroducing an unnormalised list. + */ + class Row( val ord: Int, val exercise: String, val isWarmup: Boolean, @@ -36,8 +52,11 @@ object LiftMetrics { val reps: Int? = null, val rpe: Double? = null, val primaryMuscle: LiftMuscle? = null, - val secondaryMuscles: List = emptyList(), - ) + secondaryMuscles: List = emptyList(), + ) { + val secondaryMuscles: List = + LiftMuscle.decodeList(LiftMuscle.encodeList(secondaryMuscles, primaryMuscle)) + } // MARK: - Volume load (tonnage) diff --git a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt index f7f5ca6a17..fbb2a5beff 100644 --- a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt +++ b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt @@ -22,6 +22,12 @@ import org.junit.Test * - Curl has one set with no weight and one with no reps, so the fallback compares 0.0 against 20. * - Set 2 lists `chest` as both primary and secondary; it must be counted once. * - Set 4 is 13 reps, one past the ceiling, so it estimates nil rather than a number. + * - Dip lists `triceps` three times and the primary `chest` once. Swift's `LiftSetRow.init` + * normalises a row's secondaries at construction, so `LiftMetrics` never sees a repeat; + * `LiftMetrics.Row` has to carry the same invariant or the muscle is counted once per + * mention. Unnormalised, this row credited triceps 1.0 across two indirect sets where + * Swift credits 0.5 across one. The `normalisedSecondaries` block pins the list itself, + * not just the totals it feeds, so a regression names the cause rather than a stray sum. * * The oracle only guards this direction. `LiftMetricsTests` on the Swift side is what stops Swift * drifting away from Kotlin. @@ -46,6 +52,9 @@ class LiftMetricsParityOracleTest { row(6, "Curl", null, 10, 8.0, false, LiftMuscle.biceps, emptyList()), row(7, "Curl", 20.0, null, 8.0, false, LiftMuscle.biceps, emptyList()), row(8, "Plank", 0.0, 0, null, false, null, emptyList()), + row(9, "Dip", 50.0, 6, 8.5, false, LiftMuscle.chest, + listOf(LiftMuscle.triceps, LiftMuscle.triceps, LiftMuscle.chest, + LiftMuscle.frontDelts, LiftMuscle.triceps)), ) private fun f(d: Double?) = if (d == null) "nil" else String.format(Locale.ROOT, "%.6f", d) @@ -54,7 +63,7 @@ class LiftMetricsParityOracleTest { /** Verbatim stdout of the Swift build. Do not hand-edit: regenerate from the oracle. */ private val expected = """ == volumeLoadKg == - 3120.000000 + 3420.000000 nil nil == sessionLoad == @@ -73,19 +82,31 @@ class LiftMetricsParityOracleTest { nil nil nil + == normalisedSecondaries == + 0|triceps,frontDelts + 1|triceps,frontDelts + 2|triceps + 3|[] + 4|biceps + 5|biceps + 6|[] + 7|[] + 8|[] + 9|triceps,frontDelts == perExercise == Bench|3|1|1520.000000|90.000000|10|120.000000 Row|2|0|1600.000000|70.000000|8|88.666667 Curl|2|0|nil|20.000000|nil|nil Plank|1|0|nil|0.000000|0|nil + Dip|1|0|300.000000|50.000000|6|60.000000 == rpeProfile == - 7.900000|5|3|4|8.000000 - 7.900000|5|3|4|7.000000 + 8.000000|6|3|5|8.000000 + 8.000000|6|3|5|7.000000 nil|0|0|0|8.000000 == muscleCounts == - chest|3.000000|3|nil - frontDelts|0.500000|nil|1 - triceps|1.000000|nil|2 + chest|4.000000|4|nil + frontDelts|1.000000|nil|2 + triceps|1.500000|nil|3 lats|2.000000|2|nil biceps|3.000000|2|2 == constants == @@ -118,6 +139,12 @@ class LiftMetricsParityOracleTest { out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(null, 5))) out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(100.0, null))) + out.appendLine("== normalisedSecondaries ==") + for (s in sets) { + val sec = s.secondaryMuscles + out.appendLine("${s.ord}|" + if (sec.isEmpty()) "[]" else sec.joinToString(",") { it.name }) + } + out.appendLine("== perExercise ==") for (s in LiftMetrics.perExercise(sets)) { out.appendLine(