@dgpholdings/greatoak-shared 1.2.86 → 1.2.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +148 -148
  2. package/dist/__mocks__/exercises.mock.js +1 -0
  3. package/dist/types/TApiAiExerciseAnalysis.d.ts +2 -1
  4. package/dist/types/TApiClientConstellation.d.ts +33 -0
  5. package/dist/types/TApiClientConstellation.js +13 -0
  6. package/dist/types/TApiExercise.d.ts +5 -3
  7. package/dist/types/index.d.ts +1 -0
  8. package/dist/utils/constellation/computeNormalisedLoad.d.ts +48 -0
  9. package/dist/utils/constellation/computeNormalisedLoad.js +150 -0
  10. package/dist/utils/constellation/evaluateConstellation.d.ts +27 -0
  11. package/dist/utils/constellation/evaluateConstellation.js +135 -0
  12. package/dist/utils/constellation/index.d.ts +17 -0
  13. package/dist/utils/constellation/index.js +26 -0
  14. package/dist/utils/constellation/levelThresholds.d.ts +99 -0
  15. package/dist/utils/constellation/levelThresholds.js +123 -0
  16. package/dist/utils/constellation/starFoundation.d.ts +25 -0
  17. package/dist/utils/constellation/starFoundation.js +54 -0
  18. package/dist/utils/constellation/stars/consistency.d.ts +29 -0
  19. package/dist/utils/constellation/stars/consistency.js +142 -0
  20. package/dist/utils/constellation/stars/lowerBody.d.ts +17 -0
  21. package/dist/utils/constellation/stars/lowerBody.js +30 -0
  22. package/dist/utils/constellation/stars/pull.d.ts +11 -0
  23. package/dist/utils/constellation/stars/pull.js +24 -0
  24. package/dist/utils/constellation/stars/push.d.ts +11 -0
  25. package/dist/utils/constellation/stars/push.js +24 -0
  26. package/dist/utils/constellation/stars/quality.d.ts +19 -0
  27. package/dist/utils/constellation/stars/quality.js +98 -0
  28. package/dist/utils/constellation/stars/recovery.d.ts +29 -0
  29. package/dist/utils/constellation/stars/recovery.js +169 -0
  30. package/dist/utils/constellation/strengthStarHelpers.d.ts +41 -0
  31. package/dist/utils/constellation/strengthStarHelpers.js +104 -0
  32. package/dist/utils/constellation/types.d.ts +124 -0
  33. package/dist/utils/constellation/types.js +18 -0
  34. package/dist/utils/index.d.ts +5 -3
  35. package/dist/utils/index.js +1 -0
  36. package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +59 -36
  37. package/dist/utils/scoringWorkout/calculateQualityScore.js +234 -233
  38. package/dist/utils/scoringWorkout/computeMuscleFatigueMap.d.ts +8 -5
  39. package/dist/utils/scoringWorkout/computeMuscleFatigueMap.js +72 -88
  40. package/dist/utils/scoringWorkout/constants.d.ts +20 -6
  41. package/dist/utils/scoringWorkout/constants.js +23 -9
  42. package/dist/utils/scoringWorkout/helpers.d.ts +7 -0
  43. package/dist/utils/scoringWorkout/helpers.js +24 -18
  44. package/dist/utils/scoringWorkout/index.d.ts +12 -8
  45. package/dist/utils/scoringWorkout/index.js +23 -15
  46. package/dist/utils/scoringWorkout/parseRecords.js +4 -3
  47. package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +210 -172
  48. package/dist/utils/scoringWorkout/types.d.ts +34 -14
  49. package/package.json +31 -31
  50. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +0 -30
  51. package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +0 -138
  52. package/dist/utils/scaleProPlan.util.d.ts +0 -9
  53. package/dist/utils/scaleProPlan.util.js +0 -139
  54. package/dist/utils/scoring/calculateCalories.d.ts +0 -67
  55. package/dist/utils/scoring/calculateCalories.js +0 -345
  56. package/dist/utils/scoring/calculateMuscleFatiue.d.ts +0 -67
  57. package/dist/utils/scoring/calculateMuscleFatiue.js +0 -310
  58. package/dist/utils/scoring/calculateQualityScore.d.ts +0 -71
  59. package/dist/utils/scoring/calculateQualityScore.js +0 -334
  60. package/dist/utils/scoring/calculateTotalVolume.d.ts +0 -15
  61. package/dist/utils/scoring/calculateTotalVolume.js +0 -73
  62. package/dist/utils/scoring/constants.d.ts +0 -211
  63. package/dist/utils/scoring/constants.js +0 -247
  64. package/dist/utils/scoring/helpers.d.ts +0 -119
  65. package/dist/utils/scoring/helpers.js +0 -229
  66. package/dist/utils/scoring/index.d.ts +0 -28
  67. package/dist/utils/scoring/index.js +0 -47
  68. package/dist/utils/scoring/parseRecords.d.ts +0 -98
  69. package/dist/utils/scoring/parseRecords.js +0 -284
  70. package/dist/utils/scoring/types.d.ts +0 -86
  71. package/dist/utils/scoring/types.js +0 -11
  72. package/dist/utils/scoring.utils.d.ts +0 -14
  73. package/dist/utils/scoring.utils.js +0 -243
  74. /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.d.ts → calculateMuscleFatigue.d.ts} +0 -0
  75. /package/dist/utils/scoringWorkout/{calculateMuscleFatiue.js → calculateMuscleFatigue.js} +0 -0
@@ -5,156 +5,181 @@ exports.calculateQualityScore = calculateQualityScore;
5
5
  const constants_1 = require("./constants");
6
6
  const helpers_1 = require("./helpers");
7
7
  // ---------------------------------------------------------------------------
8
- // Main Quality Calculation
8
+ // Effort scoring tuning (kept local — these describe the score CURVE, not the
9
+ // physiological "optimal zone" the old [min,max] ranges encoded).
9
10
  // ---------------------------------------------------------------------------
10
11
  /**
11
- * Calculate the overall quality score and its breakdown.
12
- *
13
- * @param parsedSets Cleaned sets (from parseRecords) only completed sets
14
- * @param rawRecords Original TRecord[] needed for completion count (includes skipped)
15
- * @param timingGuardrails Exercise's guardrails for rest period validation
16
- * @param isStrictTimingModeScoring If false, ignores the rest discipline penalty.
17
- * @param userContext User context for dynamic weighting (P2-3)
18
- * @param historicalContext Optional history for progressive overload detection (P2-6)
19
- * @returns { score: 0–100, breakdown: { completion, consistency, effortAdequacy, restDiscipline } }
12
+ * Hard floor for any logged effort. Below the old EFFORT_MIN_SCORE (50) on
13
+ * purpose: now that every set logs RPE/RIR, a genuinely easy working set must
14
+ * be allowed to score lower than a no-data session ever could. 20 keeps the
15
+ * very-easy end from being a literal zero (a logged easy set is still better
16
+ * data than nothing) while letting it sink the session score.
17
+ */
18
+ const EFFORT_FLOOR_SCORE = 20;
19
+ /**
20
+ * RPE score. Linear, monotonic, peaking at RPE 9 = 100.
21
+ * score = 25 + (rpe - 1) * 9.375 (clamped 20–100)
22
+ * RPE 10 (grinding past failure every set) is capped slightly below 100 (95)
23
+ * so the model does not reward chronic to-failure training as "perfect".
24
+ */
25
+ const EFFORT_RPE_BASE = 25;
26
+ const EFFORT_RPE_SLOPE = 9.375; // (100 - 25) / (9 - 1)
27
+ /**
28
+ * RIR → score. Inverse of RPE (RIR = reps in reserve, lower = harder).
29
+ * score = 100 - rir * 8 (clamped 20–100)
30
+ * RIR 0–1 ≈ failure → ~100; RIR 10 → 20.
31
+ */
32
+ const EFFORT_RIR_SLOPE = 8;
33
+ /**
34
+ * Final-set emphasis for effort averaging. The last set's effort score is
35
+ * weighted this many times a normal set. 2.0 = the finish counts double.
36
+ */
37
+ const EFFORT_FINAL_SET_WEIGHT = 2.0;
38
+ // ---------------------------------------------------------------------------
39
+ // Main entry point
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Calculate the overall quality score and its component breakdown.
20
43
  *
21
- * @example
22
- * const { score, breakdown } = calculateQualityScore(parsed, raw, guardrails, false, userCtx, historyCtx);
23
- * // score: 81
24
- * // breakdown: { completion: 100, consistency: 75, effortAdequacy: 80, restDiscipline: 65 }
44
+ * @param parsedSets Cleaned completed sets (output of parseRecords)
45
+ * @param rawRecords Original TRecord[] needed for completion count and RPE/RIR
46
+ * @param timingGuardrails Exercise DB guardrails — for rest period validation
47
+ * @param historicalContext Optional cross-session context for overload detection
25
48
  */
26
- function calculateQualityScore(parsedSets, rawRecords, timingGuardrails, isStrictTimingModeScoring, userContext, historicalContext) {
27
- var _a;
28
- // Edge case: no records at all or NONE were completed
49
+ function calculateQualityScore(parsedSets, rawRecords, timingGuardrails, historicalContext) {
50
+ // Nothing completed at all
29
51
  if (rawRecords.length === 0 || parsedSets.length === 0) {
30
- const emptyScoresByGoal = {};
31
- for (const goal of userContext.fitnessGoals) {
32
- emptyScoresByGoal[goal] = {
33
- score: 0,
34
- qualityBreakdown: {
35
- completion: 0,
36
- consistency: 0,
37
- effortAdequacy: 0,
38
- restDiscipline: 0,
39
- },
40
- };
41
- }
42
52
  return {
43
- scoresByGoal: emptyScoresByGoal,
53
+ score: 0,
54
+ qualityBreakdown: {
55
+ completion: 0,
56
+ consistency: 0,
57
+ effortAdequacy: 0,
58
+ restDiscipline: 0,
59
+ },
60
+ restDisciplineActive: false,
44
61
  };
45
62
  }
63
+ // ── Three always-active components ──────────────────────────────────────
46
64
  const completion = calculateCompletion(parsedSets, rawRecords);
47
65
  const consistency = calculateConsistency(parsedSets, historicalContext);
48
66
  const effortAdequacy = calculateEffortAdequacy(rawRecords);
49
- const restDiscipline = isStrictTimingModeScoring ? calculateRestDiscipline(parsedSets, timingGuardrails) : constants_1.REST_NO_DATA_SCORE;
50
- const breakdownBase = {
51
- completion: Math.round(completion),
52
- consistency: Math.round(consistency),
53
- effortAdequacy: Math.round(effortAdequacy),
54
- restDiscipline: Math.round(restDiscipline),
55
- };
56
- // P2-3: Dynamic Quality Weights based on fitnessGoal
57
- const goalWeights = {
58
- strength: { completion: 0.15, consistency: 0.25, effortAdequacy: 0.45, restDiscipline: 0.15 },
59
- hypertrophy: { completion: 0.20, consistency: 0.35, effortAdequacy: 0.30, restDiscipline: 0.15 },
60
- endurance: { completion: 0.25, consistency: 0.40, effortAdequacy: 0.20, restDiscipline: 0.15 },
61
- fat_loss: { completion: 0.30, consistency: 0.30, effortAdequacy: 0.25, restDiscipline: 0.15 },
62
- mobility: { completion: 0.40, consistency: 0.35, effortAdequacy: 0.10, restDiscipline: 0.15 },
63
- general_fitness: { completion: 0.20, consistency: 0.35, effortAdequacy: 0.30, restDiscipline: 0.15 },
64
- rehabilitation: { completion: 0.40, consistency: 0.40, effortAdequacy: 0.10, restDiscipline: 0.10 },
65
- sport_performance: { completion: 0.20, consistency: 0.30, effortAdequacy: 0.35, restDiscipline: 0.15 },
66
- core_strength: { completion: 0.25, consistency: 0.35, effortAdequacy: 0.25, restDiscipline: 0.15 },
67
- };
68
- const scoresByGoal = {};
69
- for (const goal of userContext.fitnessGoals) {
70
- const goalWeight = (_a = goalWeights[goal]) !== null && _a !== void 0 ? _a : constants_1.QUALITY_WEIGHTS;
71
- const goalScore = Math.round(completion * goalWeight.completion +
72
- consistency * goalWeight.consistency +
73
- effortAdequacy * goalWeight.effortAdequacy +
74
- restDiscipline * goalWeight.restDiscipline);
75
- scoresByGoal[goal] = {
76
- score: (0, helpers_1.clamp)(goalScore, 0, 100),
77
- qualityBreakdown: breakdownBase,
78
- };
79
- }
67
+ // ── Conditional: rest discipline ─────────────────────────────────────────
68
+ // null = no rest data logged → excluded from scoring entirely.
69
+ // The 15% weight is redistributed to the three active components below.
70
+ // Two-gate check for rest discipline:
71
+ //
72
+ // Gate 1 — raw data: did the user actually log rest timing?
73
+ // Check rawRecords (not parsedSets). parseRecords synthesises rest values
74
+ // from timingGuardrails.typical for non-last sets even when the user never
75
+ // logged rest, making parsedSets an unreliable signal.
76
+ //
77
+ // Gate 2 evaluable sets: does calculateRestDiscipline have non-last sets to score?
78
+ // Even if raw data exists, a single-set exercise has its only set marked as
79
+ // last-set parsedSet.restDurationSecs = null no evaluable sets.
80
+ // calculateRestDiscipline returns null in this case.
81
+ //
82
+ // restDisciplineActive = true only when BOTH gates pass real data existed
83
+ // AND produced an evaluable score.
84
+ const hasRawRestData = rawRecords.some((r) => r.restDurationSecs !== undefined && r.restDurationSecs !== null);
85
+ const restDiscipline = hasRawRestData
86
+ ? calculateRestDiscipline(parsedSets, timingGuardrails) // may return null for single-set
87
+ : null;
88
+ // ── Dynamic weight normalisation ─────────────────────────────────────────
89
+ //
90
+ // With rest: activeWeightTotal = 1.00 → standard weights apply
91
+ // Without rest: activeWeightTotal = 0.85 → remaining three components
92
+ // are normalised to fill 100% of the score space
93
+ //
94
+ // Using the sum of active weights rather than a hardcoded 0.85 means any
95
+ // future adjustments to QUALITY_WEIGHTS in constants.ts propagate here
96
+ // automatically without touching this logic.
97
+ const activeWeightTotal = restDiscipline !== null
98
+ ? 1.0
99
+ : constants_1.QUALITY_WEIGHTS.completion +
100
+ constants_1.QUALITY_WEIGHTS.consistency +
101
+ constants_1.QUALITY_WEIGHTS.effortAdequacy;
102
+ const weightedSum = completion * constants_1.QUALITY_WEIGHTS.completion +
103
+ consistency * constants_1.QUALITY_WEIGHTS.consistency +
104
+ effortAdequacy * constants_1.QUALITY_WEIGHTS.effortAdequacy +
105
+ (restDiscipline !== null
106
+ ? restDiscipline * constants_1.QUALITY_WEIGHTS.restDiscipline
107
+ : 0);
108
+ const score = (0, helpers_1.clamp)(Math.round(weightedSum / activeWeightTotal), 0, 100);
80
109
  return {
81
- scoresByGoal,
110
+ score,
111
+ qualityBreakdown: {
112
+ completion: Math.round(completion),
113
+ consistency: Math.round(consistency),
114
+ effortAdequacy: Math.round(effortAdequacy),
115
+ restDiscipline: restDiscipline !== null ? Math.round(restDiscipline) : 0,
116
+ },
117
+ restDisciplineActive: restDiscipline !== null,
82
118
  };
83
119
  }
84
120
  // ---------------------------------------------------------------------------
85
- // Component A: Completion (0–100)
121
+ // Component A Completion (0–100)
86
122
  // ---------------------------------------------------------------------------
87
123
  /**
88
- * Did the user complete all their sets?
89
- *
90
- * Simple ratio: completedSets / totalSets × 100
91
- *
92
- * This rewards finishing what you started. Skipping sets tanks the score.
93
- *
94
- * Uses rawRecords.length as total (includes isDone:false) and
95
- * parsedSets.length as completed (only isDone:true after filtering).
124
+ * Ratio of completed sets to planned sets.
125
+ * Skipping sets directly reduces this score. Completing everything = 100.
96
126
  */
97
127
  function calculateCompletion(parsedSets, rawRecords) {
98
128
  const total = rawRecords.length;
99
129
  if (total === 0)
100
130
  return 0;
101
- const completed = parsedSets.length;
102
- return (completed / total) * 100;
131
+ return (parsedSets.length / total) * 100;
103
132
  }
104
133
  // ---------------------------------------------------------------------------
105
- // Component B: Consistency (0–100)
134
+ // Component B Consistency (0–100)
106
135
  // ---------------------------------------------------------------------------
107
136
  /**
108
137
  * Were sets consistent in performance output?
109
138
  *
110
- * Measures the coefficient of variation (CV) of the per-set "output metric".
111
- * CV = 0 → perfectly consistent → score 100
112
- * CV = 0.50 → very variable → score ~0 (clamped to minimum)
139
+ * Uses coefficient of variation (CV) of a per-set output metric.
140
+ * CV = 0 → perfectly consistent → 100
141
+ * CV = 0.5 → very variable → clamped to CONSISTENCY_MIN_SCORE
113
142
  *
114
- * The "output metric" depends on exercise type:
115
- * weight-reps: kg × reps (volume per set)
116
- * reps-only: reps (aux weight change is intentional, so just reps)
117
- * duration: durationSecs
118
- * cardio-machine: avg speed (speedMin + speedMax) / 2
119
- * cardio-free: effective speed (distance / time)
143
+ * Output metric by record type:
144
+ * weight-repskg × reps (volume per set — handles pyramids and backoffs)
145
+ * reps-only rep count
146
+ * duration → hold time in seconds
147
+ * cardio-machine speed
148
+ * cardio-free effective speed (distance / time)
120
149
  *
121
- * SPECIAL CASEProgressive overload detection:
122
- * If values are monotonically increasing (warming up / pyramiding UP)
123
- * or monotonically decreasing (drop sets / fatigue), this is INTENTIONAL.
124
- * We don't penalize it — instead, we floor the score at 75.
125
- * Requires ≥ 3 sets to detect (2 sets are always monotonic).
150
+ * Progressive overload protection intentional patterns are NOT penalised:
126
151
  *
127
- * P2-6: Progressive overload across sessions
128
- * If the session volume matches or beats the previous session's volume,
129
- * we also floor the score at 75 to reward progression.
152
+ * Intra-session (≥ 3 sets required — 2 sets are always trivially monotonic):
153
+ * Monotonically ascending [warm-up pyramid, RPT] → floor at PROGRESSIVE_OVERLOAD_FLOOR
154
+ * Monotonically descending [drop sets] → floor at PROGRESSIVE_OVERLOAD_FLOOR
155
+ * Top-set + stable backoffs [500, 640, 640] → non-strictly ascending → protected
130
156
  *
131
- * SINGLE SET: Returns 100 (nothing to compare against).
157
+ * Cross-session (P2-6):
158
+ * Current total volume ≥ previous session volume → floor at PROGRESSIVE_OVERLOAD_FLOOR
159
+ * Rewards users who beat last session even when intra-set CV is high.
160
+ *
161
+ * Single set → 100 (nothing to compare against).
132
162
  */
133
163
  function calculateConsistency(parsedSets, historicalContext) {
134
164
  if (parsedSets.length <= 1)
135
165
  return 100;
136
166
  const values = parsedSets.map(extractConsistencyMetric);
137
- // Filter out zero/invalid values
138
167
  const validValues = values.filter((v) => v > 0);
139
168
  if (validValues.length <= 1)
140
169
  return 100;
141
- // Calculate coefficient of variation
142
170
  const cv = (0, helpers_1.coefficientOfVariation)(validValues);
143
- // Base score: penalize variance
144
171
  let score = (0, helpers_1.clamp)(100 - cv * constants_1.CONSISTENCY_CV_PENALTY, constants_1.CONSISTENCY_MIN_SCORE, 100);
145
172
  let isProgressiveOverload = false;
146
- // P2-6: Cross-session progressive overload
147
- const currentSessionVolume = validValues.reduce((sum, v) => sum + v, 0);
148
- if ((historicalContext === null || historicalContext === void 0 ? void 0 : historicalContext.previousSessionVolume) && currentSessionVolume > 0) {
149
- if (currentSessionVolume >= historicalContext.previousSessionVolume) {
150
- isProgressiveOverload = true;
151
- }
173
+ // Cross-session: beat last session's total volume?
174
+ const currentVolume = validValues.reduce((sum, v) => sum + v, 0);
175
+ if ((historicalContext === null || historicalContext === void 0 ? void 0 : historicalContext.previousSessionVolume) !== undefined &&
176
+ historicalContext.previousSessionVolume > 0 &&
177
+ currentVolume >= historicalContext.previousSessionVolume) {
178
+ isProgressiveOverload = true;
152
179
  }
153
- // Intra-session progressive overload detection
154
- if (validValues.length >= 3) {
155
- const isMonotonicallyIncreasing = isMonotonic(validValues, "asc");
156
- const isMonotonicallyDecreasing = isMonotonic(validValues, "desc");
157
- if (isMonotonicallyIncreasing || isMonotonicallyDecreasing) {
180
+ // Intra-session: monotonic pattern (≥ 3 sets)
181
+ if (!isProgressiveOverload && validValues.length >= 3) {
182
+ if (isMonotonic(validValues, "asc") || isMonotonic(validValues, "desc")) {
158
183
  isProgressiveOverload = true;
159
184
  }
160
185
  }
@@ -164,46 +189,39 @@ function calculateConsistency(parsedSets, historicalContext) {
164
189
  return score;
165
190
  }
166
191
  /**
167
- * Extract the consistency metric for a set based on its type.
192
+ * Extract the consistency metric for one set based on its record type.
193
+ *
194
+ * weight-reps uses kg × reps (integrated volume) rather than kg alone:
195
+ * Straight sets: [80×8, 80×8, 80×8] → [640, 640, 640] CV = 0 → 100
196
+ * Top set + backoffs: [100×5, 80×8, 80×8] → [500, 640, 640] ascending → floor
197
+ * Pyramid up: [60×12, 80×8, 100×5] → [720, 640, 500] descending → floor
198
+ * Drop sets: [100×8, 80×10, 60×12] → [800, 800, 720] descending → floor
168
199
  */
169
200
  function extractConsistencyMetric(set) {
170
201
  var _a, _b, _c, _d, _e, _f, _g;
171
202
  switch (set.type) {
172
- case "weight-reps": {
173
- // Volume per set: weight × reps
174
- const kg = (_a = set.kg) !== null && _a !== void 0 ? _a : 0;
175
- const reps = (_b = set.reps) !== null && _b !== void 0 ? _b : 0;
176
- return kg * reps;
177
- }
178
- case "reps-only": {
179
- // Just reps (aux weight changes are intentional, not inconsistency)
203
+ case "weight-reps":
204
+ return ((_a = set.kg) !== null && _a !== void 0 ? _a : 0) * ((_b = set.reps) !== null && _b !== void 0 ? _b : 0);
205
+ case "reps-only":
180
206
  return (_c = set.reps) !== null && _c !== void 0 ? _c : 0;
181
- }
182
- case "duration": {
183
- // Hold duration
207
+ case "duration":
184
208
  return (_d = set.durationSecs) !== null && _d !== void 0 ? _d : set.activeDurationSecs;
185
- }
186
- case "cardio-machine": {
187
- // Average speed
188
- const speed = (_e = set.speed) !== null && _e !== void 0 ? _e : 0;
189
- return speed;
190
- }
209
+ case "cardio-machine":
210
+ return (_e = set.speed) !== null && _e !== void 0 ? _e : 0;
191
211
  case "cardio-free": {
192
- // Effective speed (km/h)
193
212
  const distance = (_f = set.distance) !== null && _f !== void 0 ? _f : 0;
194
- const durationSecs = (_g = set.cardioDurationSecs) !== null && _g !== void 0 ? _g : set.activeDurationSecs;
195
- if (durationSecs > 0 && distance > 0) {
196
- return distance / (durationSecs / 3600);
197
- }
198
- return 0;
213
+ const durationSec = (_g = set.cardioDurationSecs) !== null && _g !== void 0 ? _g : set.activeDurationSecs;
214
+ return durationSec > 0 && distance > 0
215
+ ? distance / (durationSec / 3600)
216
+ : 0;
199
217
  }
200
218
  default:
201
219
  return 0;
202
220
  }
203
221
  }
204
222
  /**
205
- * Check if an array of numbers is monotonically increasing or decreasing.
206
- * "Allowing equal" [10, 10, 12] counts as ascending.
223
+ * True when values are non-strictly monotonic in the given direction.
224
+ * Equal consecutive values are allowed: [500, 640, 640] is ascending.
207
225
  */
208
226
  function isMonotonic(values, direction) {
209
227
  for (let i = 1; i < values.length; i++) {
@@ -215,143 +233,126 @@ function isMonotonic(values, direction) {
215
233
  return true;
216
234
  }
217
235
  // ---------------------------------------------------------------------------
218
- // Component C: Effort Adequacy (0–100)
236
+ // Component C Effort Adequacy (0–100)
219
237
  // ---------------------------------------------------------------------------
220
238
  /**
221
- * Was the user working in a productive effort zone?
222
- *
223
- * The "productive zone" for most training goals:
224
- * RIR 1–4: close enough to failure to stimulate adaptation, not so close
225
- * that recovery is impaired
226
- * RPE 6–9: same concept from the subjective side
239
+ * Was the user working hard enough to drive adaptation?
227
240
  *
228
- * Scoring per set:
229
- * - Within optimal range 100
230
- * - Outside by 1 unit 80
231
- * - Outside by 2 units 60
232
- * - Outside by 3+ units → 40/20
241
+ * Effort is now logged on EVERY set (post-set one-tap modal), so scoring is
242
+ * graded and monotonic rather than a flat optimal-band. Each set is scored,
243
+ * then averaged with the FINAL set weighted EFFORT_FINAL_SET_WEIGHT× because
244
+ * the last working set is where adequacy is truly decided.
233
245
  *
234
- * If neither RPE nor RIR is provided70 (neutral, can't confirm or deny).
246
+ * RPE: 9 100, 7 ~81, 4~53, 1 25 (floored at 20)
247
+ * RIR: 0 → 100, 3 → 76, 6 → 52, 10 → 20
235
248
  *
236
- * Uses RAW records (not parsed sets) because:
237
- * 1. We need the original RPE/RIR strings
238
- * 2. We only score completed sets (isDone === true)
249
+ * RIR is preferred over RPE when both exist (more objective — countable reps).
250
+ * A set with NO effort data (legacy/pre-update records only) scores the neutral
251
+ * EFFORT_NO_DATA_SCORE so historical sessions in the Gate 2 window don't break.
239
252
  */
240
253
  function calculateEffortAdequacy(rawRecords) {
241
254
  const completedRecords = rawRecords.filter((r) => r.isDone);
242
255
  if (completedRecords.length === 0)
243
256
  return 0;
244
- const setScores = [];
245
- for (const record of completedRecords) {
246
- setScores.push(scoreSetEffort(record));
247
- }
248
- // Average all set effort scores
249
- return setScores.reduce((sum, s) => sum + s, 0) / setScores.length;
257
+ const setScores = completedRecords.map(scoreSetEffort);
258
+ // Weighted mean final set counts EFFORT_FINAL_SET_WEIGHT×.
259
+ const lastIndex = setScores.length - 1;
260
+ let weightedSum = 0;
261
+ let weightTotal = 0;
262
+ setScores.forEach((s, i) => {
263
+ const w = i === lastIndex ? EFFORT_FINAL_SET_WEIGHT : 1;
264
+ weightedSum += s * w;
265
+ weightTotal += w;
266
+ });
267
+ return weightTotal > 0 ? weightedSum / weightTotal : constants_1.EFFORT_NO_DATA_SCORE;
250
268
  }
251
- /**
252
- * Score a single set's effort level.
253
- *
254
- * Priority: RIR > RPE > no data.
255
- * For RIR: optimal range [1, 4]
256
- * For RPE: optimal range [6, 9]
257
- */
258
269
  function scoreSetEffort(record) {
259
- // Try RIR first (more objective)
270
+ // RIR preferred (more objective — countable remaining reps)
260
271
  if (record.rir !== undefined && record.rir !== "") {
261
- const rir = (0, helpers_1.safeParseFloat)(record.rir, -1);
262
- if (rir >= 0) {
263
- return scoreAgainstRange(rir, constants_1.OPTIMAL_RIR_RANGE);
272
+ const rir = parseFloat(record.rir);
273
+ if (!isNaN(rir) && rir >= 0) {
274
+ return scoreEffortRir(rir);
264
275
  }
265
276
  }
266
- // Try RPE
277
+ // RPE fallback
267
278
  if (record.rpe !== undefined && record.rpe !== "") {
268
- const rpe = (0, helpers_1.safeParseFloat)(record.rpe, -1);
269
- if (rpe >= 1) {
270
- return scoreAgainstRange(rpe, constants_1.OPTIMAL_RPE_RANGE);
279
+ const rpe = parseFloat(record.rpe);
280
+ if (!isNaN(rpe) && rpe >= 1) {
281
+ return scoreEffortRpe(rpe);
271
282
  }
272
283
  }
273
- // No effort data — neutral score
284
+ // No effort data — legacy/pre-update sessions only. Neutral, never a penalty.
274
285
  return constants_1.EFFORT_NO_DATA_SCORE;
275
286
  }
276
287
  /**
277
- * Score a value against an optimal range.
278
- *
279
- * Within [min, max] 100
280
- * Distance 1 outside → 100 - penalty
281
- * Distance 2 outside → 100 - 2×penalty
282
- * etc., floored at EFFORT_MIN_SCORE
288
+ * Graded RPE effort score. Monotonic: harder always scores higher.
289
+ * score = EFFORT_RPE_BASE + (rpe - 1) * EFFORT_RPE_SLOPE (clamped 20–100)
290
+ * RPE 10 capped at 95 — chronic grinding past failure is not "perfect".
283
291
  */
284
- function scoreAgainstRange(value, range) {
285
- const [min, max] = range;
286
- if (value >= min && value <= max)
287
- return 100;
288
- // Distance from nearest edge of the range
289
- const distance = value < min ? min - value : value - max;
290
- return Math.max(100 - distance * constants_1.EFFORT_DISTANCE_PENALTY, constants_1.EFFORT_MIN_SCORE);
292
+ function scoreEffortRpe(rpe) {
293
+ const clamped = (0, helpers_1.clamp)(rpe, 1, 10);
294
+ if (clamped > 9)
295
+ return 95;
296
+ const score = EFFORT_RPE_BASE + (clamped - 1) * EFFORT_RPE_SLOPE;
297
+ return (0, helpers_1.clamp)(score, EFFORT_FLOOR_SCORE, 100);
298
+ }
299
+ /**
300
+ * Graded RIR → effort score. Inverse of RPE (RIR lower = harder).
301
+ * score = 100 - rir * EFFORT_RIR_SLOPE (clamped 20–100)
302
+ */
303
+ function scoreEffortRir(rir) {
304
+ const clamped = (0, helpers_1.clamp)(rir, 0, 10);
305
+ const score = 100 - clamped * EFFORT_RIR_SLOPE;
306
+ return (0, helpers_1.clamp)(score, EFFORT_FLOOR_SCORE, 100);
291
307
  }
292
308
  // ---------------------------------------------------------------------------
293
- // Component D: Rest Discipline (0–100)
309
+ // Component D Rest Discipline (0–100, conditional)
294
310
  // ---------------------------------------------------------------------------
295
311
  /**
296
312
  * Were rest periods within the exercise's optimal windows?
297
313
  *
298
- * Uses the exercise's timingGuardrails.restPeriods:
299
- * optimalRange [min, max] 100 points
300
- * within [minimum, maximum] 70–100 (proportional to how close to optimal)
301
- * outside [minimum, maximum] → 50 points (Reduced penalty so it doesn't tank the score too much)
314
+ * Only called when parsedSets has at least one non-null restDurationSecs.
315
+ * The caller (calculateQualityScore) handles conditional inclusion and
316
+ * weight redistribution this function only scores what it receives.
302
317
  *
303
- * Only scores sets that HAVE rest data (last set often doesn't).
304
- * If no valid rest data exists at all neutral 70.
318
+ * Scoring bands (using timingGuardrails.restPeriods from exercise DB):
319
+ * Within optimalRange 100
320
+ * Within [minimum, maximum] → 70–99 proportional to proximity to optimal
321
+ * Outside [minimum, maximum] → 50 (soft penalty — doesn't crater the score)
305
322
  *
306
- * The stressRestBonus from timingGuardrails is applied as a final multiplier:
307
- * exercises where proper rest is especially important (heavy compounds)
308
- * get a boost when rest is done right.
323
+ * stressRestBonus (from exercise timingGuardrails):
324
+ * Heavy compounds benefit more from proper rest. A small bonus is applied
325
+ * when avgScore > 60 and the exercise carries a stressRestBonus value.
309
326
  */
310
327
  function calculateRestDiscipline(parsedSets, timingGuardrails) {
311
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
312
- // Gather sets that have rest data (non-null)
313
- const setsWithRest = parsedSets.filter((s) => s.restDurationSecs !== null);
328
+ var _a, _b, _c, _d, _e, _f, _g, _h;
329
+ const setsWithRest = parsedSets.filter((s) => s.restDurationSecs !== null && s.restDurationSecs !== undefined);
330
+ // No evaluable sets (e.g. single-set exercise where the only set is the last set).
331
+ // Return null so the caller knows there is genuinely no data to score,
332
+ // distinct from a score of 0 (which would penalise the user unfairly).
314
333
  if (setsWithRest.length === 0)
315
- return constants_1.REST_NO_DATA_SCORE;
316
- // Extract rest period bounds from guardrails
334
+ return null;
317
335
  const restConfig = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.restPeriods;
318
- const optimalMin = (_b = (_a = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : ((_c = restConfig === null || restConfig === void 0 ? void 0 : restConfig.typical) !== null && _c !== void 0 ? _c : constants_1.FALLBACK_REST_SECS) * 0.75;
319
- const optimalMax = (_e = (_d = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _d === void 0 ? void 0 : _d[1]) !== null && _e !== void 0 ? _e : ((_f = restConfig === null || restConfig === void 0 ? void 0 : restConfig.typical) !== null && _f !== void 0 ? _f : constants_1.FALLBACK_REST_SECS) * 1.25;
320
- const acceptableMin = (_g = restConfig === null || restConfig === void 0 ? void 0 : restConfig.minimum) !== null && _g !== void 0 ? _g : optimalMin * 0.5;
321
- const acceptableMax = (_h = restConfig === null || restConfig === void 0 ? void 0 : restConfig.maximum) !== null && _h !== void 0 ? _h : optimalMax * 2;
322
- const setScores = [];
323
- for (const set of setsWithRest) {
324
- const rest = set.restDurationSecs; // Guaranteed non-null by filter
325
- if (rest >= optimalMin && rest <= optimalMax) {
326
- // Within optimal range → perfect score
327
- setScores.push(100); // REST_OPTIMAL_SCORE
328
- }
329
- else if (rest >= acceptableMin && rest <= acceptableMax) {
330
- // Within acceptable range but not optimal
331
- // Score proportionally based on how close to optimal, base is 70 now
332
- const baseAcceptableScore = 70;
333
- let ratio;
334
- if (rest < optimalMin) {
335
- // Between acceptable min and optimal min
336
- ratio = (rest - acceptableMin) / (optimalMin - acceptableMin);
337
- }
338
- else {
339
- // Between optimal max and acceptable max
340
- ratio = (acceptableMax - rest) / (acceptableMax - optimalMax);
341
- }
342
- setScores.push(baseAcceptableScore +
343
- ratio * (100 - baseAcceptableScore));
336
+ const typicalRest = (_a = restConfig === null || restConfig === void 0 ? void 0 : restConfig.typical) !== null && _a !== void 0 ? _a : constants_1.FALLBACK_REST_SECS;
337
+ const optimalMin = (_c = (_b = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : typicalRest * 0.75;
338
+ const optimalMax = (_e = (_d = restConfig === null || restConfig === void 0 ? void 0 : restConfig.optimalRange) === null || _d === void 0 ? void 0 : _d[1]) !== null && _e !== void 0 ? _e : typicalRest * 1.25;
339
+ const acceptMin = (_f = restConfig === null || restConfig === void 0 ? void 0 : restConfig.minimum) !== null && _f !== void 0 ? _f : optimalMin * 0.5;
340
+ const acceptMax = (_g = restConfig === null || restConfig === void 0 ? void 0 : restConfig.maximum) !== null && _g !== void 0 ? _g : optimalMax * 2;
341
+ const setScores = setsWithRest.map((set) => {
342
+ const rest = set.restDurationSecs;
343
+ if (rest >= optimalMin && rest <= optimalMax)
344
+ return 100;
345
+ if (rest >= acceptMin && rest <= acceptMax) {
346
+ const base = 70;
347
+ const ratio = rest < optimalMin
348
+ ? (rest - acceptMin) / (optimalMin - acceptMin)
349
+ : (acceptMax - rest) / (acceptMax - optimalMax);
350
+ return base + ratio * (100 - base);
344
351
  }
345
- else {
346
- // Outside even the acceptable range, reduced penalty to 50
347
- setScores.push(50);
348
- }
349
- }
350
- // Average rest scores
352
+ return 50;
353
+ });
351
354
  let avgScore = setScores.reduce((sum, s) => sum + s, 0) / setScores.length;
352
- // Apply stressRestBonus (P1-4): exercises where proper rest matters more
353
- // (e.g., heavy compounds) get a point bonus if they performed well.
354
- const stressRestBonus = (_j = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.stressRestBonus) !== null && _j !== void 0 ? _j : 0;
355
+ const stressRestBonus = (_h = timingGuardrails === null || timingGuardrails === void 0 ? void 0 : timingGuardrails.stressRestBonus) !== null && _h !== void 0 ? _h : 0;
355
356
  if (avgScore > 60 && stressRestBonus > 0) {
356
357
  avgScore = Math.min(100, avgScore + stressRestBonus);
357
358
  }
@@ -10,18 +10,22 @@
10
10
  *
11
11
  * Recovery model accounts for:
12
12
  * P2-7 Age — recovery slows with age (< 35 → 1.0×, > 55 → 0.85×)
13
- * P2-8 Gender — females recover slightly faster (1.05×)
14
13
  * P2-9 fitnessLevel — sedentary 0.8× → very-active 1.15×
15
14
  * P3-7 Weekly volume — muscles trained multiple times this week recover slower
16
15
  * P3-8 Muscle group — small muscles (biceps) recover 1.4× faster than legs (0.80×)
17
16
  *
18
17
  * Accumulation model:
19
- * P3-3 Real muscleScores → additive with diminishing returns (P3-5)
20
- * Legacy (muscleScores={}) → quality score × 0.7/0.3 proxy + MAX accumulation
18
+ * P3-3 Real per-session muscleScores → additive with diminishing returns (P3-5)
19
+ *
20
+ * Note: the legacy quality-score proxy path (pre-P3-1 sessions with empty
21
+ * muscleScores) has been removed — every session now saves real muscleScores.
22
+ * The P2-8 gender recovery factor has also been removed: the 1.05× female
23
+ * bump was within the noise of the model and not well-evidenced enough to
24
+ * justify a silent default-male assumption for unmentioned-gender users.
21
25
  */
22
26
  import type { TBodyPartKeys, TUserMetric } from "../../types";
23
27
  import type { TEnrichedExerciseRecord, TMuscleFatigueResult } from "./types";
24
- interface IExerciseMuscleData {
28
+ export interface IExerciseMuscleData {
25
29
  primaryMuscles: TBodyPartKeys;
26
30
  secondaryMuscles: TBodyPartKeys;
27
31
  }
@@ -35,4 +39,3 @@ interface IExerciseMuscleData {
35
39
  * @returns Record<muscleKey, { fatigue 0–100, lastWorked }>
36
40
  */
37
41
  export declare function computeMuscleFatigueMap(exercises: Record<string, IExerciseMuscleData>, scoreHistory: TEnrichedExerciseRecord[], user?: Partial<TUserMetric>, currentDate?: Date): TMuscleFatigueResult;
38
- export {};