Skip to content

Introduce a generic v2 training engine - merge previous PR #186

Description

@giulioleuci

What problem does this solve?

I plan to merge #179, #181, and #168, reimagining them within a more generic, composable, and reusable common engine. Here the spec.

Proposed approach

1. Core Philosophy and Goals

The system replaces a legacy, hardcoded progression model (based on scattered fields like weight, reps, seconds) with a single, deterministic training-prescription engine.

The engine is completely pure, synchronous, and stateless. It relies on no external dependencies, no I/O, no random number generation, and no implicit timestamps. Given the same resolved bindings, history, frozen references, and compile inputs, the engine guarantees identical semantic prescriptions. Once compiled, a prescription is immutable; logged performance never edits the prescription itself, and future program changes only affect future exposures.

2. Data Structure Architecture

The fundamental shift in this architecture is the transition to relational, immutable dictionaries and the separation of the active session from the synchronized state.

2.1 Profile Root Changes

The canonical state tree is augmented with specific, append-only or immutable collections, while highly volatile state is removed:

  • engineSchemaVersion: 2 is added.
  • trainingReferences: An append-only dictionary keyed by reference ID.
  • engineSnapshots: A content-addressed, immutable dictionary keyed by a hash of the snapshot body.
  • Active Session Removal: The in-progress workout session is entirely removed from the synchronized state tree to prevent massive overhead during workout logging.

2.2 The Active Session Split

Previously, every logged set, weight change, or keystroke mutated the main synchronized profile tree, triggering full state serializations and debounced server uploads (e.g., 3 serializations of a 5MB object and one API push per checkbox toggle).

The Solution:
The active workout session is decoupled into a completely isolated, local-only storage key.
When an athlete modifies an active session, the system only serializes a ~5 KB object locally. It does not update timestamps, does not touch the synchronized profile, and arms no server uploads. The active session is only merged back into the canonical profile once, exactly when the workout is explicitly finished.

2.3 TrainingReference (Append-Only)

References used to calculate relative loads (e.g., 1RM, Training Max, Body Mass) are frozen in time. Updating a 1RM never overwrites old data; it appends a new record.

{
  schemaVersion: 1,
  exerciseId: string, // omitted for body-mass
  kind: 'one-rep-max' | 'training-max' | 'anchor-load' | 'body-mass',
  value: number,
  unit: 'kg' | 'lb',
  source: 'measured' | 'estimated' | 'manual' | 'derived',
  formula: string, // optional
  t: number // epoch ms timestamp
}

2.4 Content-Addressed Snapshots and Exposures

To achieve maximum deduplication and prepare for future database migrations, prescriptions are split into two entities: the stateless Snapshot and the stateful Exposure.

PrescriptionSnapshot (The Hashed Body):
Crucially, the snapshot body contains nothing that varies per exposure. It holds no id, no createdAt, and no position (like run or exposure ordinals).

{
  schemaVersion: 1,
  engineVersion: "2.0.0",
  binding: { /* Resolved Binding params */ },
  sets: [ /* Prescribed sets */ ],
  evaluation: { /* Evaluation policies */ },
  reasons: []
}

Deduplication in action: Two exposures collide and share the exact same snapshot when the binding, the frozen references, and the concrete set counts are identical. This is common for repeats, stalls, deload weeks, and manual/off days.

PrescribedExposure (The Pointer):
The volatile information lives here. This record exists inside a workout entry and points to the immutable snapshot.

{
  schemaVersion: 1,
  exposureId: string,
  kind: 'prescribed' | 'legacy',
  exerciseId: string,
  trackId: string,
  snapshotId: string, // Points to engineSnapshots key
  exposureOrdinal: number, // 1-based, within the track
  runOrdinal: number, // 1-based, within current run
  performance: { /* Logged performance */ }
}

2.5 Performance and Unprescribed Sets

Logged performance is separated from prescribed targets.

  • Unprescribed Sets: If an athlete adds a set mid-session, it is recorded as prescribed: false with no associated prescription ID. It contributes to total volume and personal record detection, but it is never evaluated against a target (it cannot succeed or fail). Because the prescription is never rewritten, it remains genuinely immutable.
  • Skipped Sets: Removing a prescribed set records it as status: 'skipped'.

3. System Composability: Registry, Presets, and Bindings

The system is highly composable, built on a strict hierarchy of Primitives, Presets, and Bindings.

3.1 The Closed Registry (Primitives)

The system uses a strictly controlled registry of primitives defining:

  • Metrics: repetitions, duration, distance, speed, pace, rounds, power, heart-rate.
  • Resistance Kinds:
    • external-load (absolute weight)
    • bodyweight (uses the frozen body-mass reference)
    • assistance (lower is harder; comparison inverts)
    • ordered-scale (bands, machine pins; never converted to absolute weight)
  • Targets: exact, minimum, maximum, or range. Range goals are not failure conditions; any observed value within [min, max] satisfies the target.

3.2 Presets

Presets are declarative, versioned compositions of primitives (e.g., id@version). They define rules without executing arbitrary JavaScript or formulas.

Universal AMRAP (N+):
N+ is a universal primitive available in any preset, not just specific programs. It is defined as a minimum goal with an independent termination criteria (e.g., technical-failure, effort-limit).
Example: A planner sets a rep range of 8–12 and flags it with openUpperBound: true. This means "achieve at least 8, but continue to failure." If the athlete logs 14 reps, it satisfies the target and triggers an improved outcome badge.

Set Templates and Cloning:
A SetTemplate can declare a repeat value (default 1). The compiler expands this, giving each cloned set a stable ID (e.g., templateId#1, templateId#2). This allows presets to easily vary set counts without requiring unstructured lists.

3.3 Parameters and Bindings

Binding parameters are strictly typed: number, boolean, enum, measurement, range, scale-value.

  • Ranges: Range parameters (like loadRange, repRange, setCountRange) are valid inputs. However, the final resolved prescription must convert these into concrete goals.
  • Occurrence Modes: When a routine is scheduled, exercises resolve via three modes:
    • inherit: Uses the routine's default binding, applying explicit parameter overrides.
    • override: Owns a completely independent binding.
    • off: Uses a core manual fallback. It means "no automatic advancement," not "no prescription."

4. The Pure Runtime Pipeline

The engine executes in a strictly sequential, pure pipeline of synchronous functions:

  1. validatePreset → Validates the declarative schema.
  2. validateBinding → Checks parameters against preset requirements.
  3. resolveBinding → Merges routine defaults with occurrence overrides.
  4. compile → Takes the resolved binding, history, and frozen references to produce the immutable PrescriptionSnapshot. The output is deeply frozen (Object.freeze) to prevent accidental mutation.
  5. evaluate → Compares the snapshot against logged performance.
  6. analyze → Contextualizes the evaluation against history to generate an Outcome.
  7. decide → Determines the next action (advance, repeat, recover, etc.).

4.1 History Resolution (No Indexes)

Instead of maintaining a complex, globally updated index of history that needs to be invalidated whenever state changes, the engine utilizes a reverse scan. It traverses workout history backward, exiting as soon as it finds the necessary comparable exposures (typically within 1–3 workouts). Failure streaks are derived on demand via this scan and are never persisted in the database.

5. Content Addressing and Determinism

To guarantee that snapshots with the same semantics share the same ID, the system enforces a strict serialization protocol before hashing.

5.1 Canonical Serialization

  1. undefined values are stripped.
  2. Object keys are recursively sorted in ascending code-unit order.
  3. Array order is strictly preserved.
  4. Numbers are normalized (e.g., 1 and 1.0 both serialize as 1). Non-finite numbers throw a hard error.
    Example: {b:{y:1,x:[1,{q:2,p:3}]},a:'é'} is serialized exactly as {"a":"é","b":{"x":[1,{"p":3,"q":2}],"y":1}}.

5.2 FNV-1a-64 Hashing

The canonical JSON string is converted to raw UTF-8 bytes and hashed using a synchronous FNV-1a 64-bit algorithm. It outputs a 16-character zero-padded hex string.

  • If a hash collision occurs where the keys match but the canonical JSON differs, the engine explicitly throws an error rather than silently corrupting data.

6. Examples of Composable Presets in Action

The architecture supports recreating complex real-world programs purely through declarative primitives. Progression axes are clearly defined per preset:

  • Linear Progression (strength.linear@1):
    The simplest advancement model. It waits for target success across all prescribed sets, then directly advances the external load by the configured step. If the athlete stalls, it repeats the load; if they reach the consecutive-stall threshold, it applies a recovery action (deload).
  • Double Progression (strength.double@1):
    Utilizes a range compiler on two axes (repetitions and load). The athlete must first raise their repetitions within a declared rep range across all sets. Only when the upper bound of the rep range is achieved does the engine advance the external load—simultaneously resetting the repetition targets back to the range minimum for the next exposure.
  • Triple Progression (strength.triple@1):
    Utilizes a volume-ladder compiler across three axes: repetitions, set count, and load. The engine first demands the athlete raises repetitions. Once maxed, it increases the set count. Once the maximum set count is achieved at the maximum repetitions, the external load is finally increased, and both repetitions and set count are reset to their respective minimums.
  • Classic 5/3/1 (strength.531@1):
    Utilizes frozen references. The Training Max is derived as 90% of the 1RM reference. Sets use relative loads: 65 % × 5, 75 % × 5, 85 % × 5+. After a completed cycle, it appends a new Training Max reference to the append-only dictionary.
  • Pyramids (strength.pyramid@1):
    Sets are defined as 12 @ 70 %, 10 @ 85 %, 8 @ 100 % anchor. The anchor role is the only progression driver. Non-anchor rows are required for completion but do not trigger load advancement.
  • Self-Regulated Volumes (core.self-regulated@1):
    Uses a setCountRange parameter. The athlete chooses how many sets to perform. The compiler pre-compiles concrete sets clamped to [min, max]. Completing fewer sets (e.g., 3 instead of 5) inside the declared range evaluates as succeeded and does not increment a failure streak.

7. Migration Strategy for Existing Data

To transition from the legacy hardcoded system to the engine, a robust, atomic migration process runs strictly before any state is adopted, merged, restored, or written. Legacy state is never merged with a canonical state.

7.1 Structural Conversions

During the migration pass:

  • The system stamps engineSchemaVersion: 2 at the root.
  • Primary Key Backfill: Every historical workout missing a unique id is assigned one deterministically. This resolves composite-key fallbacks and provides a clean primary key for future database migrations.
  • The active workout session is safely extracted from the synchronized profile and moved to the new local-only storage key, idempotently.

7.2 Routine and Policy Mapping

Legacy routines are converted to canonical ones, generating occurrenceIds for exercises. The old legacy progression flags are mapped 1-to-1 to their new, declarative preset equivalents:

  • off → core.manual@1
  • linear → strength.linear@1
  • greyskull → strength.greyskull-lp@1
  • double → strength.double@1
  • time → duration.linear@1
  • Bodyweight exercises flagged as linear → bodyweight.volume-ladder@1

Crucially, migration never guesses. Complex presets like Triple Progression, Pyramids, or 5/3/1 are strictly opt-in post-migration. The system will never assume an athlete's past intentions if they fall outside the 1:1 legacy mapping.

7.3 Historical Entries and Active Workouts

  • Reconstructable History: If a historical workout entry has enough saved target information, the engine generates a valid PrescriptionSnapshot retroactively, places it in engineSnapshots, and converts the entry into a PrescribedExposure.
  • Canonical Legacy Exposures: If an entry lacks sufficient data to reconstruct a self-sufficient policy (or is ambiguous), it becomes a canonical legacy exposure (kind: 'legacy', no snapshot ID). It remains fully visible in the user's history but is excluded from progression logic, ensuring it never falsely triggers a failure streak for a new track.
  • In-Progress Workouts: If an athlete has a workout actively running during the update, it is converted seamlessly. Prescribed targets become a frozen snapshot, and logged rows become observed performance. Extra sets logged that cannot be matched are marked as prescribed: false without dropping any entered work.

7.4 Protection and Client Gates

Before the very first v2 replacement is sent to the server, the client forces an atomic write of an immutable backup (e.g., state-<uid>.pre-engine-v1.json). On mobile devices, this backup is written directly to the native filesystem and strictly verified via read-back before the new state is adopted.

Once upgraded, the backend API enforces a strict schema gate (X-OpenGym-Engine-Schema: 2). Older client versions attempting to sync will receive an upgrade-required rejection, guaranteeing that legacy clients cannot corrupt or downgrade canonical data.

8. Database Migration Readiness

The defining feature of this architecture is how it handles state synchronization and future database migrations.

Because engineSnapshots and trainingReferences are designed as immutable, append-only dictionaries:

  1. They map perfectly to a relational table where the dictionary key is the Primary Key.
  2. Every write operation becomes an idempotent INSERT ... ON CONFLICT DO NOTHING.
  3. There is no update path, no delete path, and no conflict resolution logic required for these entities.

When two offline devices sync, their snapshot dictionaries can be blindly union-merged by key. Because the hashing is deterministic (same key = same content) and references only append, this union is provably lossless. Garbage collection (sweeping unreferenced snapshots) only occurs when generating derived, read-only outputs (like exporting a plan bundle), never on the live synchronized state tree.

Dependencies

  • This doesn't require adding a new dependency

Activity

  1. giulioleuci commented on Sep 20, 2026

    @giulioleuci
    ContributorAuthor

    I have heavily modified the proposal, to align it where possible with the future 1.4 (migration to a relational database)

  2. giulioleuci commented on Sep 26, 2026

    @giulioleuci
    ContributorAuthor

    Implemented by #312

  3. DuarteSantos8 commented on Sep 28, 2026

    @DuarteSantos8
    Owner

    Thanks for the spec and for PR #312. This belongs to the v1.3.12 'Progression engine' milestone, so I'll review #312 there. Worth knowing before a rebase: v1.3.9 (https://github.com/DuarteSantos8/openGym/releases/tag/v1.3.9) changed how sessions are built — the plan now owns the reps (with a setting to start from the last session instead), progression runs per routine slot with a fallback to the exercise's history, and an edited plan restarts progression. The engine will need to keep those rules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions