Summary
Swift and Kotlin import the same valid Liftosaur workout from a heterogeneous history array, but report different rejected-record counts. Swift's pattern-filtering loop silently discards non-object elements; Kotlin counts them as skipped records.
This is a product-contract drift, not a parity-harness problem. Route the correction upstream and keep LiftingImporter.parse/2 quarantined from promotion until the product behavior is aligned.
Exact representative input and outputs
Use this history order:
{
"history": [
{
"startTime": 1748772000000,
"endTime": 1748775600000,
"dayName": "Day 1",
"entries": [
{ "sets": [ { "weight": 80, "completedReps": 5 } ] }
]
},
7,
{
"dayName": "Missing timestamp",
"entries": [
{ "sets": [ { "weight": 60, "completedReps": 8 } ] }
]
},
"not a record"
]
}
Both platforms produce exactly one session, in oldest-first order:
- start: Unix
1748772000 (2025-06-01T10:00:00Z)
- end: Unix
1748775600 (2025-06-01T11:00:00Z)
- volume load:
400.0 kg
- sets:
1
- exercises:
1
- total reps:
5
- top set:
80.0 kg
- title:
Day 1
- Swift earliest/latest: the session start
- Kotlin firstDay/lastDay:
2025-06-01
The rejected count diverges:
- Swift:
skipped = 1
- Kotlin:
skipped = 3
The missing-timestamp object is counted by both. The numeric and string array elements are counted by Kotlin but silently filtered by Swift.
Cause
Swift iterates with:
for case let record as [String: Any] in history {
if let s = liftosaurSession(record) { sessions.append(s) } else { skipped += 1 }
}
for case excludes non-dictionaries before the body, so those malformed history entries never increment skipped.
Kotlin explicitly calls optJSONObject(i) and increments skipped when it returns null, then also increments when an object cannot form a session.
Contract
Both implementations document the same tolerant contract: a malformed Liftosaur set or record is "skipped and counted, never fatal." The result models also describe skipped as the number of dropped Liftosaur records. Kotlin follows that contract for every history element; Swift does not.
Live product reachability
This is not test-only behavior.
- Swift
DataSourcesView calls LiftingImporter.parse(data:), persists every returned session as a WorkoutRow, and surfaces/logs result.skipped in the import result.
- Android
DataSourcesScreen calls LiftingImporter.importExport, which dispatches through parse, persists returned sessions via WhoopRepository, reloads Workouts, and includes result.skipped in ImportSummary.
The valid workout and its ordering/persistence are identical. The user-visible import accounting and diagnostic log differ.
Provenance
The divergent loops and shared "skipped and counted" contract were introduced with the original lifting importer in c4c320e8 (v2.11.0). Later Swift commit d4757ffb changed template-set handling and number grouping, not the history loop. Later Kotlin refactoring changed syntax but preserved explicit scalar counting.
The behavior remains byte-for-byte present on parity baseline 032983d2bed9bf50dfcfc4d06ef707f3818cdc26 and current upstream main b0d8a29fbc89836fc4257b9511af63629a2daa18.
Severity
P2. The discrepancy is visible in import summaries/logs and violates the documented rejection-accounting contract, but it does not lose a valid workout, reorder valid sessions, corrupt stored fields, or affect Effort/scoring.
Duplicate search
Searched open and closed issues and pull requests in both bhelm/noop and ryanbr/noop for Liftosaur, parseLiftosaur, LiftingImporter, heterogeneous history, scalar history elements, malformed records, and skipped counts. No duplicate exists.
Acceptance criteria
Upstream-only product routing; do not patch or promote the parity harness as a substitute.
- Add mirrored public regression tests using the exact four-element history above.
- Assert the complete valid session fields and oldest-first order on both platforms.
- Assert canonical
skipped = 3 on both platforms.
- On unchanged code, Swift must fail only on
1 != 3; Kotlin must pass as the control.
- Apply the smallest Swift-only production correction unless an authoritative contract changes.
- Preserve valid-session parsing, order, timestamps/date span, persistence, notes, scoring, APIs, and package graphs.
- Run focused, affected, and full Swift/Kotlin suites; revert any temporary Linux compatibility gates byte-exactly.
- Keep
LiftingImporter.parse/2 quarantined from parity promotion until the upstream fix is reviewed and merged.
Summary
Swift and Kotlin import the same valid Liftosaur workout from a heterogeneous
historyarray, but report different rejected-record counts. Swift's pattern-filtering loop silently discards non-object elements; Kotlin counts them as skipped records.This is a product-contract drift, not a parity-harness problem. Route the correction upstream and keep
LiftingImporter.parse/2quarantined from promotion until the product behavior is aligned.Exact representative input and outputs
Use this history order:
{ "history": [ { "startTime": 1748772000000, "endTime": 1748775600000, "dayName": "Day 1", "entries": [ { "sets": [ { "weight": 80, "completedReps": 5 } ] } ] }, 7, { "dayName": "Missing timestamp", "entries": [ { "sets": [ { "weight": 60, "completedReps": 8 } ] } ] }, "not a record" ] }Both platforms produce exactly one session, in oldest-first order:
1748772000(2025-06-01T10:00:00Z)1748775600(2025-06-01T11:00:00Z)400.0kg11580.0kgDay 12025-06-01The rejected count diverges:
skipped = 1skipped = 3The missing-timestamp object is counted by both. The numeric and string array elements are counted by Kotlin but silently filtered by Swift.
Cause
Swift iterates with:
for caseexcludes non-dictionaries before the body, so those malformed history entries never incrementskipped.Kotlin explicitly calls
optJSONObject(i)and incrementsskippedwhen it returns null, then also increments when an object cannot form a session.Contract
Both implementations document the same tolerant contract: a malformed Liftosaur set or record is "skipped and counted, never fatal." The result models also describe
skippedas the number of dropped Liftosaur records. Kotlin follows that contract for every history element; Swift does not.Live product reachability
This is not test-only behavior.
DataSourcesViewcallsLiftingImporter.parse(data:), persists every returned session as aWorkoutRow, and surfaces/logsresult.skippedin the import result.DataSourcesScreencallsLiftingImporter.importExport, which dispatches throughparse, persists returned sessions viaWhoopRepository, reloads Workouts, and includesresult.skippedinImportSummary.The valid workout and its ordering/persistence are identical. The user-visible import accounting and diagnostic log differ.
Provenance
The divergent loops and shared "skipped and counted" contract were introduced with the original lifting importer in
c4c320e8(v2.11.0). Later Swift commitd4757ffbchanged template-set handling and number grouping, not the history loop. Later Kotlin refactoring changed syntax but preserved explicit scalar counting.The behavior remains byte-for-byte present on parity baseline
032983d2bed9bf50dfcfc4d06ef707f3818cdc26and current upstream mainb0d8a29fbc89836fc4257b9511af63629a2daa18.Severity
P2. The discrepancy is visible in import summaries/logs and violates the documented rejection-accounting contract, but it does not lose a valid workout, reorder valid sessions, corrupt stored fields, or affect Effort/scoring.
Duplicate search
Searched open and closed issues and pull requests in both
bhelm/noopandryanbr/noopforLiftosaur,parseLiftosaur,LiftingImporter, heterogeneous history, scalar history elements, malformed records, and skipped counts. No duplicate exists.Acceptance criteria
Upstream-only product routing; do not patch or promote the parity harness as a substitute.
skipped = 3on both platforms.1 != 3; Kotlin must pass as the control.LiftingImporter.parse/2quarantined from parity promotion until the upstream fix is reviewed and merged.