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..7a9b58d187 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt @@ -0,0 +1,293 @@ +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, + * 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, + val weightKg: Double? = null, + val reps: Int? = null, + val rpe: Double? = null, + val primaryMuscle: LiftMuscle? = null, + secondaryMuscles: List = emptyList(), + ) { + val secondaryMuscles: List = + LiftMuscle.decodeList(LiftMuscle.encodeList(secondaryMuscles, primaryMuscle)) + } + + // 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. + * + * 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 { + 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..fbb2a5beff --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt @@ -0,0 +1,190 @@ +package com.noop.analytics + +import com.noop.data.LiftMuscle +import java.util.Locale +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. + * - 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. + */ +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()), + 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) + 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 == + 3420.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 + == 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 == + 8.000000|6|3|5|8.000000 + 8.000000|6|3|5|7.000000 + nil|0|0|0|8.000000 + == muscleCounts == + 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 == + 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("== 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( + "${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()) + } +} 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()) + } +}