LogIt

Progression algorithms

A progression algorithm decides your next sets for an exercise, given its history. The user picks one in Settings → Progression; the app calls it every time it needs a suggestion.

The contract

export const pluginBundle = {
  formatVersion: 1,
  pluginId: "com.you.my-algorithm",
  family: "progression-algorithm",
  entryExport: "algorithm",
} as const;

export const algorithm = {
  id: "com.you.my-algorithm",
  name: "My Algorithm",
  description: "Double progression — fill the rep range, then add weight.",
  defaultState: { increment: 2.5 },

  suggest(input) {
    const inc = Number(input.state?.increment ?? 2.5);
    const last = input.history[0]?.sets ?? [];
    const topReps = Math.max(0, ...last.map((s) => s.reps));
    const lastWeight = last[0]?.weight ?? 20;

    const addWeight = topReps >= 8;
    return {
      sets: [{ reps: addWeight ? 5 : [5, 8], weight: addWeight ? lastWeight + inc : lastWeight }],
      nextState: input.state,
      notes: addWeight ? `Add ${inc}kg` : "Add a rep",
    };
  },
};

Input

suggest(input: ProgressionInput) — see @logit/core/domain/progression.

FieldNotes
exercise{ name, id?, primaryMuscles?, exerciseType? }
historyExerciseHistoryEntry[], most recent first. Each has sets, performedAtMs, optional planned.
stateYour algorithm’s own opaque value — whatever you returned as nextState last time. Starts as defaultState.
userPreferencesValues the user set, if you declare a preferencesSchema (see below).
plannedTargetsSets/reps/weight from the user’s split, if any.
sessionContextPreceding exercises this session + their effort, for fatigue-aware logic.
liveInputOptional, no built-in algorithm reads it: { rpe?, readiness? }, a live today-only signal for autoregulation plugins. rpe mirrors the 6-10 SetEntry.rpe scale; readiness is deliberately unscaled — define your own meaning.
suggestedTrialRepRangeOptional [min, max] warm-start value for a rep-range experiment on this exercise, informed by what’s already worked for sibling exercises sharing its primary muscle. Bespoke to the rep-range-experimentation feature (see reasoning and nudges below) — you can ignore it unless you’re implementing something similar.
siblingRuledOutRepRangesOptional [min, max][] — rep ranges a sibling exercise’s own experiment already ran to conclusion without switching (ruled out). Only meaningful alongside suggestedTrialRepRange.

Output

type ProgressionOutput = {
  sets: { reps: number | [min, max]; weight: number; setType?: string; note?: string }[];
  nextState: unknown;      // persisted by the app, handed back next call
  label?: string;
  notes?: string;
  displayMode?: "summary" | "block";
  reasoning?: Reasoning;         // optional "show your work" — see below
  nudge?: ProgressionNudge;      // optional dismissible ask — see below
};

State lives in the return value, not in your module. Every call gets a fresh VM.

Reasoning and nudges

Both fields below are optional — omit them and your algorithm behaves exactly like before. Built-in algorithms populate both; they exist so a suggestion is never just a bare assertion, to a developer debugging and to a user squinting at a nudge alike.

reasoning (@logit/core/domain/reasoning) — the inputs your algorithm looked at, the numbers it derived from them, a confidence level, and the verdict those numbers produced:

type Reasoning = {
  inputs: Record<string, number | string | boolean>;
  computed: Record<string, number>;
  confidence: "low" | "medium" | "high";  // how much data supports this, NOT how favorable it is
  verdict: string;
};

Shown to the user behind a generic “Why?” affordance next to the suggestion — you don’t build any UI for this yourself.

nudge (ProgressionNudge, same domain module) — a dismissible, trackable ask, distinct from the static notes string:

type ProgressionNudge = {
  id: string;              // stable per algorithm+kind, e.g. "my-algo:vary-order"
  message: string;
  actionLabel?: string;    // if present, the UI shows an accept button with this label
  actionData?: unknown;    // data your own usecase needs to act on an accepted nudge
  exclusive?: boolean;     // true if accepting this should be the only such thing running account-wide
};

Dismissal is handled generically — input.state/your own logic doesn’t need to track which nudges the user has already dismissed; the app filters those out before your algorithm is even asked again. actionLabel/actionData only matter if you also register whatever usecase interprets an accept for your id — most algorithms have nothing to act on and just use message alone.

Preferences

Add a settings screen by exporting a schema:

export const algorithm = {
  // …
  defaultPreferences: { increment: 2.5, repRange: [5, 8] },
  preferencesSchema: [
    { key: "increment", label: "Weight increment (kg)", type: "number", default: 2.5, min: 0.5, step: 0.5 },
    { key: "repRange", label: "Rep range", type: "range", default: [5, 8], min: 1, max: 20 },
  ],
};

The values arrive as input.userPreferences.

Build & test

npx esbuild src/index.ts --bundle --format=esm --outfile=bundle.js

Test suggest as a plain function — it’s pure. Then publish.