Skip to content

Drop-set option for Old School mode: negative Weight Change Per Rep on failed reps only #673

Description

@9thLevelSoftware

Issue #673 — Drop Sets on Failure (user-prompted) Implementation Plan

Authoritative replacement plan. The earlier automatic deload/cross-set proposal, closed PR #682, and branch enhancement/drop-set-old-school-673 are superseded. For Hermes: implement this plan as three sequential PRs (see “Phasing”). PR 1 and PR 2 are prerequisites for PR 3 but ship independent value. This changes set-advancement behavior, so verify normal routine advancement, superset interleaving, and variable warm-up sets remain unchanged.

GitHub issue: #673
Superseded branch: enhancement/drop-set-old-school-673
PR scope: replace the automatic deload-driven drop set with a user-prompted, rest-screen drop set that restarts the failed set.

Goal

When a set ends because the user failed (stall detection fired), offer them a drop set during the rest screen:

  1. Prompt: “Retry this set with a drop set?”
  2. If yes, offer negative percentage options (e.g. −10% / −20% / −30%).
  3. Restart the set that failed at the reduced weight — not the next set, not the next exercise.
  4. Remaining sets of that same exercise inherit the reduced weight.
  5. Original programming resumes at the next exercise. The saved routine is never modified.

Background: why the previous approach was abandoned

The first implementation (commits cb93489f..4e606cbe) made drop-set mode replace stall detection and reduce weight automatically on DELOAD_OCCURRED. This is not feasible, for a reason now confirmed from three directions:

  • The machine will not accept new set parameters mid-set. ActiveSessionEngine.kt:4847-4855 documents that a CONFIG frame sent mid-movement de-energises the cable (“the user perceives as ‘the set deloads’”). The user has independently observed this failing repeatedly in testing. Load can only change at a stop/start boundary.
  • The old design wasn’t a drop set anyway. It stashed dropSetNextWeightKg and applied it at the next set boundary — that is auto-regression between sets, not a drop set.
  • Firmware progression/regression cannot express it. The increment field is applied per rep, linearly, open-loop from rep 1. It is blind to failure and produces a continuously decaying ramp, whereas a drop set is a fixed lower weight for a block of reps. There is no cadence field in the protocol to change this (see Appendix A).

The new design accepts the stop/start constraint rather than fighting it. Restarting a set is the only way to express a real drop set on this hardware.

Decisions (settled with maintainer)

# Decision Resolution
1 Set numbering Replay — re-enter the same (exerciseIndex, setIndex). Add attemptNumber to CompletedSet.
2 Supersets Non-issue by construction — coordinates never move, so getNextStep resumes correctly.
3 Echo mode Excluded — no prompt, no override.
4 Re-failure Re-prompt, capped at 2 drops per set; dropSetMinWeightKg is a hard floor.
5 % of PR Store the override as a multiplier, never a resolved kg — composes with all weight sources.
6 Process death WorkoutServiceSnapshot carries the failure point and override.
7 Remaining-set weight after multiple drops Final attempted weight (cumulative multiplier), not the first drop level.

Current state verified in repo

Set completion / advancement

  • presentation/manager/ActiveSessionEngine.kt
    • handleSetCompletion() (:3975) — takes no reason parameter; there is currently no way to know why a set ended.
    • showBodyweightRepEntry branch (:4026-4035) — existing precedent for pausing completion awaiting user input (clears setCompletionInProgress, returns early).
    • startRestTimer() (:4420+) — captures completedSetIndex (:4426), pre-loads next-set params (:4487-4499). Does not mutate the set/exercise indices.
    • startNextSetOrExercise() (:4821+) — mutates indices at :4866-4867; weight-precedence ladder at :4877-4891.
    • advanceToNextSetInSingleExercise() — parallel ladder at :4741-4764.
    • startWorkoutOrSetReady() (:4784) — respects the autoplay preference; correct entry point for a restart.
  • presentation/manager/RoutineFlowManager.kt
    • getNextStep() (:350) — superset interleaving (A1→B1→A2→B2).
    • Index mutation sites during rest: enterSetReady* (:1051-1052, :1141-1142), skip-to-exercise (:1415-1416).
  • presentation/manager/WorkoutCoordinator.kt
    • _userAdjustedWeightDuringRest (:294) — the closest existing analogue: an ephemeral, session-scoped weight override.
    • Drop-set scratch state (:433-439) — to be removed.
    • Stall constants (:63-85).

Stall detection

  • data/ble/MonitorDataProcessor.kt (:257-272) — parses and logs DELOAD_WARN / SPOTTER_ACTIVE, but only DELOAD_OCCURRED is surfaced.
  • data/repository/BleRepository.kt:143deloadOccurredEvents: Flow<Unit>; the status word is discarded at this boundary.
  • domain/model/SampleStatus.kt — all 8 firmware flags parsed; bit definitions confirmed correct against the official app.
  • docs/audits/2026-07-14-stall-detection-e2e-audit.md — F5 (hysteresis dead band) remains open.

To be removed / corrected

  • ActiveSessionEngine.kt:456-473 — deload→auto-reduce block.
  • RoutineFlowManager.kt:107-114progressionForSetReady bypasses the ±3kg clamp for drop-set mode.
  • util/BlePacketFactory.kt:257 — zeroes negative progression; the sole guard preventing an out-of-range increment reaching the wire.
  • util/BleConstants.kt:96-99 — offsets 0x58/0x5C are misnamed (see Appendix A).

Architecture

1. Set end reason (PR 1)

enum class SetEndReason {
    TARGET_REPS_REACHED, STALL_FAILURE, CABLE_RELEASED,
    USER_STOPPED, VBT_AUTO_END, TIMER_EXPIRED,
}

Thread through handleSetCompletion(reason: SetEndReason), park on the coordinator for the rest screen, and persist on CompletedSet. Valuable for analytics independent of drop sets (“which sets did I fail”).

2. Failure point snapshot (PR 3)

Captured at handleSetCompletion time — not read live during rest, because skip/navigation paths can move the indices.

data class SetFailurePoint(
    val exerciseIndex: Int,
    val setIndex: Int,
    val routineExerciseId: String,      // assert on re-entry; index alone is fragile
    val originalWeightPerCableKg: Float,
    val attemptNumber: Int,
    val warmupSetIndex: Int,            // >= 0 means warm-up set — do not prompt
)

3. Exercise load override (PR 3)

data class ExerciseLoadOverride(
    val exerciseIndex: Int,
    val multiplier: Float,   // cumulative across drops, e.g. 0.8 then 0.64
    val dropCount: Int,      // cap at 2
)

Held on WorkoutCoordinator. Multiplier, never resolved kg — composes with flat weights, per-set weights, and %-of-PR resolution alike.

Requires no eager clearing: it is only consulted when resolving weight for exerciseIndex, so a superset partner never matches it. Clear on routine end / workout reset only.

Refactor first: four near-duplicate weight-precedence ladders exist (startRestTimer :4487, startNextSetOrExercise :4877, advanceToNextSetInSingleExercise :4741, enterSetReadyWithAdjustments). Extract a single resolveNextSetWeight() before adding a fourth precedence tier — audit finding F7 documents how divergent hand-maintained copies of the same logic have already caused bugs here.

4. Restart is a re-entry, not an advance (PR 3)

The critical architectural point. The retry must not route through getNextStep:

restartSetWithDrop(percent):
    restore indices from SetFailurePoint (assert routineExerciseId matches)
    override = ExerciseLoadOverride(exerciseIndex, cumulativeMultiplier, dropCount + 1)
    weight   = originalWeightPerCableKg * cumulativeMultiplier, floored at dropSetMinWeightKg
    repCounter.resetCountsOnly(); resetAutoStopState()
    startWorkoutOrSetReady()          // respects autoplay

Because the coordinates never move, when the retried set completes, getNextStep(exIdx, setIdx) resumes exactly as if the failure had not happened — A1 fails → retry A1 → completes → getNextStep returns B1. Superset interleaving never learns a retry occurred.

progressionRegressionKg is untouched by this feature. A restarted set applies whatever increment the exercise has configured, ramping from the lower base.

5. Prompt (PR 3)

Shown during WorkoutState.Resting so the failed set still saves and the rest timer still runs. Gated on:
reason == STALL_FAILURE && exercise.dropSetEnabled && programMode is OldSchool && !isEchoMode && warmupSetIndex < 0 && dropCount < 2 && weight > dropSetMinWeightKg

Autoplay must be suspended while the prompt is open, or the rest timer will advance out from under the user mid-decision.

6. Schema (PR 3)

  • Add attemptNumber to CompletedSet (default 1). Without it, replay produces two indistinguishable rows at the same (exerciseIndex, setIndex) and set-count math reads 4 for a 3-set exercise.
  • Keep and repurpose the already-shipped dropSetEnabled / dropSetMinWeightKg columns (migration 43, SchemaManifest, sync repo, backup, DTOs): dropSetEnabled becomes “offer the drop-set prompt on failure”, dropSetMinWeightKg stays the floor. This preserves the migration and portal parity work.
  • WorkoutServiceSnapshot (DefaultWorkoutSessionManager.kt:564) carries SetFailurePoint + ExerciseLoadOverride. Silently losing the override mid-exercise would resume remaining sets at full weight — a surprise heavy set, the worst failure mode.

7. Stall detection rework (PR 2)

The prompt is only as good as the failure signal, and the current detector is unreliable. Two changes:

a. Widen the event to carry the status word. deloadOccurredEvents: Flow<Unit>Flow<SampleStatus> (or a MachineStatusEvent). The flags are already parsed; only the plumbing discards them.

b. Add the geometric ROM-fraction signal. The official app derived rep position from cable position as a fraction of calibrated ROM, not velocity (Appendix A). Phoenix uses velocity thresholds with position only as a crude > 10mm gate. A user grinding to failure is stuck at a characteristic fraction of their ROM — structurally different from a completed rep at the top or racked handles at the bottom. This addresses audit F5 (the 2.5–10 mm/s dead band where a countdown can neither arm nor cancel) at the root: position answers where they are stuck when velocity cannot answer whether they are stuck.

DELOAD_WARN may be useful as a leading indicator but has no reference implementation (Appendix A) — treat any use of it as novel and validate on hardware before relying on it.

8. Cleanup (PR 1)

  • Rename OFFSET_TARGET_WEIGHTOFFSET_SOFT_MAX, OFFSET_PROGRESSIONOFFSET_INCREMENT (BleConstants.kt:96-99), matching the official field names. “Progression” invited the per-rep/per-set ambiguity that produced the overloaded field.
  • Enforce the firmware validation bounds Phoenix currently ignores: softMax <= 100.0f and increment <= 10.0f. The drop-set path currently bypasses the ±3kg clamp (RoutineFlowManager.kt:107-114), so a −20kg config produces an increment of −20 — double the firmware bound, applied per rep. Only BlePacketFactory.kt:257 stops it reaching the wire.
  • Delete the deload→auto-reduce block (ActiveSessionEngine.kt:456-473), dropSetNextWeightKg and its consumption ladders, the coordinator scratch state, and the progressionRegressionKg overload.

Phasing

PR Scope Independent value
1 SetEndReason plumbing + protocol cleanup + remove old drop-set logic Set-end reasons in history; closes the increment overrun
2 Status-word flow + geometric ROM-fraction stall detection Fixes “sets randomly ending”; closes audit F5
3 Prompt + SetFailurePoint + ExerciseLoadOverride + attemptNumber The feature

Testing

  • Restart lands on the same (exerciseIndex, setIndex); getNextStep afterwards returns the same step it would have without the retry.
  • Superset A1 fails → retry A1 → next step is B1; B’s weights unmodified.
  • Override applies to remaining sets of the overridden exercise and to no other exercise.
  • Cumulative multiplier after two drops; third prompt suppressed; floor respected.
  • Echo exercises never prompt.
  • Warm-up set failure never prompts.
  • %-of-PR exercise: override composes multiplicatively.
  • Autoplay ON: rest timer does not advance while the prompt is open.
  • Process death during rest with a pending prompt, and mid-exercise with an active override.
  • Regression: normal advancement, variable warm-up sets, single-exercise mode, bodyweight rep entry.

Non-goals

  • Mid-set weight changes. Confirmed impossible; do not attempt a 0x4F force-only frame mid-set.
  • Firmware-native drop sets via negative increment. It is a per-rep open-loop ramp, not a drop set.
  • Changing progression/regression semantics or the saved routine.

Appendix A — Protocol findings from the deobfuscated official app

Source: 9thLevelSoftware/deob, cloned at ../deob (sibling of this repo).

A1. The official app parses but never acts on the deload flags. In Yj/p$j.smali — the only consumer of the flag enum in the entire APK — all eight flags are read but only REP_TOP_READY, REP_BOTTOM_READY and ROM_OUTSIDE_LOW reach the return value. ROM_OUTSIDE_HIGH, ROM_UNLOAD_ACTIVE, SPOTTER_ACTIVE, DELOAD_WARN and DELOAD_OCCURRED are invoked with no move-result — their values are discarded at bytecode level, so this is neither a decompiler artifact nor dead-code elimination.

Implication: Phoenix’s use of DELOAD_OCCURRED to drive auto-stop is an invention, not a port. There is no reference implementation for failure detection to validate against — consistent with it being hit-or-miss.

A2. Rep position was derived geometrically. The official app compares max(left.position, right.position) against rangeTop − pct·(rangeTop − rangeBottom) and rangeBottom + pct·(...), with per-exercise configurable fractions. Position, not velocity.

A3. Full activation packet layout (Ek/N.java, Ek/C1505b.java, Ek/O.java, Ek/M.java, Ek/L.java):

ActivationPacket (0x04):
  RepConfig(
    repCounts: RepCounts(total: byte, baseline: byte = 3, adaptive: byte = 3, pad = 0),
    seedRange: float,
    top:    RepBandConfig(threshold = 5.0, drift = 0.0, inner: RepBand, outer: RepBand),
    bottom: RepBandConfig(threshold = 5.0, drift = 0.0, inner: RepBand, outer: RepBand),
    safety: RepBand(mmPerM: short, mmMax: short))
  ActivationForceConfig(
    concentric: Ramp, eccentric: Ramp,
    forces: ClosedRange<Float>,
    softMax:   float,   // validated <= 100.0  ("Soft max is too high!")
    increment: float)   // validated <= 10.0   ("Increment is too high!")

Serialization order of the trailing block is forces.min, forces.max, softMax, increment, which maps onto Phoenix’s offsets as:

Offset Phoenix name Official name
0x50 forceMin forces.min
0x54 forceMax forces.max
0x58 targetWeight softMax
0x5C progression increment

A4. No cadence field exists. The only load-stepping parameter in the whole protocol is the single increment float; RegularPacket (0x4F) likewise carries one progression float. The per-rep firing rate is firmware-internal and not exposed. A fixed lower weight for a block of reps therefore requires a stop/restart with increment = 0.

A5. RepCounts(total, baseline = 3, adaptive = 3) — the 3 firmware calibration reps are a protocol-level constant, retroactively justifying Constants.DEFAULT_WARMUP_REPS = 3 (kept deliberately per Issue #411).

Caveat: BLE_PROTOCOL_DEEP_DIVE.md in that repo is partly AI-generated narrative. Its “When Sent” claims (e.g. that RegularPacket adjusts resistance mid-exercise) are unverified and contradicted by field observation. Only the decompiled sources and smali cited above are treated as evidence here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions