# onemore — plan (2026-09-06) Answer to the handoff brief: evaluation of wger and Liftosaur, the architecture, the program model, and the repo layout. This file is the architecture owner; what is built is § What is built. The full research reports with citations are in `docs/research/`; the original brief is `docs/BRIEF.md`. Everything marked *unverified* is a gap the research could not close without installing something or logging in. --- ## 1. Evaluation ### Liftosaur - **Not self-hostable in practice.** The client is a Preact PWA you can run locally offline-only. The backend is AWS Lambda + DynamoDB + S3, deployed with CDK into your own AWS account, with hand-created Secrets Manager entries and domain coupling to liftosaur.com. No Docker image. (README.) - **API is premium.** REST API and MCP server need the paid tier ($4.99/mo, $39.99/yr, $99.99 lifetime). History comes back as a text DSL ("Liftoscript Workouts") you would have to parse. Free tier has manual JSON/CSV export only. - **Single maintainer**, very active (3,344 of ~3,427 commits, pushes daily). AGPL-3.0. App Store 4.9★ over 386 ratings. Apple Watch app since Feb 2026. - **As a logger:** same manual-export friction as Strong, worse UX than Strong, paid API. Rejected. - **As prior art for the program DSL: excellent.** See §3. Grammars are in the repo (`src/pages/planner/plannerExercise.grammar`, `src/liftohistory/liftohistory.grammar`), evaluator in `src/liftoscriptEvaluator.ts`, function table in `src/liftoscriptFns.ts`, examples in `src/generated/liftoscriptExamples.ts`. ### wger - **Deploy:** seven containers (web/gunicorn, nginx, postgres, redis, celery worker, celery beat, **powersync**). amd64 + arm64 images, ~358 MB. First boot needs `manage.py setup-powersync-storage`. Config is one `prod.env`. NAS has the headroom (x86_64, ~10 GB RAM, 7 GB free as of today). - **Synology pain, specific:** wger must run on its own (sub)domain, not a subpath, and the mobile app's sync path `/ps/` needs response buffering off, Upgrade/Connection headers passed and long timeouts. DSM's built-in reverse proxy is the likely failure point; the one DSM report on GitHub (#67) closed without a fix. Would want Caddy or direct Tailscale-IP access. *Unverified* whether the iOS app accepts plain http on a Tailscale IP. - **API:** `/api/v2/`, token or JWT, OpenAPI at `/api/v2/schema`. The 2025 "flexible routines" rewrite (Routine → Day → Slot → SlotEntry → per-field config rows) is fully writable, so a generator can push a plan, but it is five-plus POSTs per exercise and there is no nested/bulk create. Per-set logs carry `weight`/`reps`/`rir` plus `*_target` twins, so planned-vs-actual is native. Official Python client `wger-api-client` exists. No webhooks; poll `workoutlog/?date__gte=`. - **Breaking API changes every release** (2.5 removed endpoints, 2.6 moved to UUIDs and dropped login endpoints, 2.7 on 2026-09-03 changed session fields and deprecated `weightentry`). One maintainer does ~98% of commits. AGPL. - **Data model gaps:** RiR only, no RPE; notes per session, not per set; CSV import/export exists for body weight only; no Strong/Hevy importer. - **Mobile logging:** official Flutter app on the App Store with a self-hosted URL field. App ≥2.0.3 hard-requires PowerSync ("Sync service unreachable" otherwise). Offline mode since 2.6. App Store has 2 ratings; no substantive gym-floor UX reports found. Against the Strong bar this is the weak point. ### Strong (re-examined, because you love it) - CSV export is free, full history every time, three taps plus the share sheet. So it does **not** need to happen after every workout. Export whenever you want the plan to adapt. - Header (verified from real exports): `Date,Workout Name,Duration,Exercise Name,Set Order,Weight,Reps,Distance,Seconds,Notes,Workout Notes,RPE` plus a 10-column "without notes" variant. Date is local `YYYY-MM-DD HH:MM:SS`, duration is `2h 38m`, no unit column (bare numbers in the app's unit), warm-up sets appear as `Set Order = W`. A semicolon/`Weight (kg)` locale variant is reported but not seen verbatim; detect it defensively. - No Shortcuts action reads workouts, no URL scheme, nothing per-set in Apple Health. Two reverse-engineered backend clients exist; Strong asked one author to take his down. Not worth the account. - Strong Pro adds nothing about data. Export is free. ### Hevy (the "later" path) - API is Pro-only ($2.99/mo, $23.99/yr, $74.99 lifetime). `api-key` header. `GET /v1/workouts` paged at max 10 per page; `GET /v1/workouts/events?since=` is the incremental-sync primitive; a webhook fires on workout creation. Set shape: `{index, type: normal|warmup|dropset|failure, weight_kg, reps, distance_meters, duration_seconds, rpe}`. Routines are POST/PUT-able with exercises, sets, `rep_range`, `rest_seconds`. Exercise names must be mapped to opaque `exercise_template_id`s first. - Imports Strong CSV natively. UX is the closest thing to Strong that has an API; reports call it "slightly busier" with a social feed. ### Verdict Between the two the brief asked about: **wger**, because Liftosaur cannot be self-hosted without an AWS account and its API is paid. But I would not deploy either first. Neither logger clears the Strong bar, and the thing you would own by self-hosting wger is a worse notebook with an API that breaks quarterly. **Recommendation: keep Strong as the logger. Feed the repo from its CSV export on the adaptation cadence, which is weekly, not per workout.** Make the export a single share-sheet action into the NAS so it survives past week three: 1. Strong → Export Strong Data → share sheet → an iOS Shortcut that POSTs the file to a webhook on the NAS over Tailscale. Four taps, no file management. Fallback: share to Synology Drive and let a folder watcher pick it up. 2. Full-history export + idempotent dedupe on (date, workout name, exercise, set order) means a missed week costs nothing. If that cadence fails in practice, the upgrade is **Hevy lifetime**, not wger: same shape of app as Strong, API + webhook, Strong importer for history. wger stays the "zero subscriptions, total ownership" option and the adapter layer keeps it reachable. Liftosaur is prior art only. ### The open question: who owns the program? **The repo owns the program. The logging app is a notebook.** - Adaptation rules must be inspectable and versioned. That means code in a repo with tests, not progression settings inside an app. - Every candidate app can *display* a routine. Only Liftosaur can *run* rules. Keeping rules in the repo is what makes the app swappable. - With Strong there is no routine push at all: the repo renders the week and you read it off a phone-sized page while logging in Strong, exactly the college-spreadsheet workflow. Hevy/wger adapters add `push_plan` later. - Consequence: in-workout autoregulation (Liftosaur's `update:` blocks) stays in your head. The engine implements only post-workout, block-level rules (Liftosaur's `progress:`), evaluated when an export lands. That is the right granularity for a rebuild program anyway. --- ## 2. Architecture ``` Strong CSV ─┐ ┌─ CLI: plan / status / explain Hevy API ──┤ sources/ → canonical → store ─┤ wger API ──┤ (adapters) model (sqlite) ├─ engine: estimate → rules → next week Health Auto Export JSON ┘ (optional enrichment) └─ outputs: phone card, Hevy/wger push ``` ### Canonical model (`onemore/model.py`) - `Session(id, source, source_key, started_at, duration, notes, bodyweight?)` - `ExerciseEntry(session, exercise_id, order, notes)` - `Set(entry, index, set_type: work|warmup|drop|failure, weight_kg, reps, rpe?, rir?, completed, source_ref)` - `Exercise(id, name, aliases{source: name}, movement pattern, muscle groups, is_main_lift)`. Aliases are a data file; Strong's `Bench Press (Barbell)`, Hevy's template id and wger's exercise id all resolve to `bench_press`. - `Prescription` and `Adjustment` (see §3) are stored too, so planned-vs-actual and the rule audit trail live in the same DB. - Store kg internally, render in the user's unit. Rounding to plates happens only at render time. ### Sources (`onemore/sources/`) ```python class Source(Protocol): name: str def fetch_sessions(self, since: datetime | None) -> Iterable[Session]: ... class Sink(Protocol): # optional capability def push_plan(self, week: RenderedWeek) -> None: ... ``` - `strong_csv.py` — parses all header variants, sniffs delimiter, takes the unit from config, maps `W` set order to warm-up, drops `Rest Timer` rows (a rest, not a set), dedupes on the natural key. Used for the one-time history import **and** the ongoing weekly feed. - `hevy.py` — `workouts/events?since=` polling plus the creation webhook; implements `Sink` via `POST /v1/routines`. Written against the Swagger spec now, exercised only when a key exists. - `wger.py` — `workoutlog/?date__gte=`; `Sink` via the routine/day/slot/config POST chain. Stub until wger is actually deployed. - `apple_health.py` — accepts Health Auto Export's JSON (metrics and workouts) on `POST /import/health` or from a file. Every reading becomes a `Metric` row; `vitals.py` turns them into latest / 7-day / 28-day rows for `status` and a one-line header on the plan. Enrichment only, never required. Format verified against the vendor's pages 2026-09-06 (`docs/research/strong-data-out.md` § Apple Health). - `apple_health_xml.py` — the Health app's own "Export All Health Data" zip, no third-party app needed. A streaming parse of `export.xml` (1.1 GB, 2.3 M records for a first export) that keeps bodyweight, body fat, lean mass, resting HR, HRV, VO2 max, respiratory rate, daily steps (largest source per day, since phone and Watch both count), one sleep row per night from the stage intervals, and the strength workouts' HR and calories — computed from raw samples where the older Watch workouts carry no statistics. Same metric names as the JSON path, so `onemore import-health` takes either by extension. The same zip also POSTs to `/import/health` straight from the share sheet, which is how it is fed: the request answers 202 and the parse runs on a worker thread, because it is far longer than a phone will wait (`DEPLOY.md` § 6). No app and no subscription — Health Auto Export's JSON remains a second, paid way in. ### Estimation (`onemore/estimate.py`) - e1RM per set: Epley for reps ≤ 10, RPE-adjusted via an RPE → %1RM table (Liftosaur's `rpeMultiplier`) when RPE is logged. - Rolling e1RM per lift: best e1RM from work sets over the last 21 days. - Training max = 0.9 × rolling e1RM (5/3/1 convention). - **Cold start (no current maxes):** week 1 is the seeded week. `onemore start` seeds every program lift's e1RM from its historical best, decayed ×0.8 past 180 days; week 1 prescribes off that e1RM with a 5+ top set, and `calibrate_tm` sets the TM from what the top set actually did. Percentages of TM start in week 2. § The seeded week and the ceiling. A main lift that joins a running plan (comeback's machine pulldown, 2026-09-21) is calibrated the same way from its first work week by `calibrate_new_lift`. Until then the page shows a render-only provisional TM, the same 0.9 × best e1RM from the 21 days before the week (`engine.provisional_tms`); with nothing logged in that window it stays blank. ### Engine (`onemore/engine.py`) `advance(program, state, history) -> (next_week_plan, adjustments)`. Pure function over stored data; the CLI and the web service both call it. Every `Adjustment` records rule name, lift, field, old → new, and the set ids that were the evidence. `onemore explain --week N` prints them. ### Deploy Python 3.13, uv, src layout, pytest, ruff (house conventions). CLI first. The HTTP service — webhook ingest plus the week as a phone page — runs as a container on a home server, bound to loopback and reached only over a private network from the phone: the inbound localhost proxy is what makes it reachable from nowhere else. Procedure, `.env` keys and the iOS Shortcut: [DEPLOY.md](DEPLOY.md). **There is no Dockerfile, and that is the design.** `dependencies = []` and a stdlib-only `web.py` mean stock `python:3.13-slim` plus `PYTHONPATH=/app/src` *is* the image, so nothing holds the code: the deploy clone is mounted read-only and a sync script pulls it and recreates the container. Adding a dependency ends that and needs a real `build:`. SQLite and the archived imports live outside the clone, since the sync force-resets it on an interval, and are on the regular backup list. The compose entry, the sync line and the two backup lines are in a separate deploy repo, outside this one. --- ## 3. Program model ### What to take from Liftoscript - Prescribed arrays vs completed arrays (`reps` vs `completedReps`) as first-class, side by side. - Per-exercise persistent state (`rm1`, counters) that rules mutate. - Two hook points: post-workout `progress:` and intra-workout `update:`. We implement the first only (see §1). - Load expressed as `%` of a state variable, absolute, or `@RPE`; `+` for AMRAP; set variations for stage-cycling (GZCLP's 5x3 → 6x2 → 10x1). - Reuse across weeks by restating only what changed. - Plate-aware rounding as a library function, not baked into rules. ### What not to take Liftosaur needs a *text* DSL because users edit programs inside an app. We have one user and a repo, so **programs are Python modules** built from a small set of dataclasses. Rules are plain functions with a docstring and a `reason`. No parser to maintain, and pytest is the inspector. ### The program as written `src/onemore/program/programs/rebuild.py` was the first program — **retired 2026-09-21**, superseded by `comeback` (§ `comeback` supersedes `rebuild`) and carrying the Epley ratchet fault in its accessories; it survives in git history. Its design, in outline: - `Sets(n, reps | (lo, hi), load, amrap=False)` renders to concrete loads. `load` is one of `Pct(x, of="tm"|"e1rm")`, `Rpe(x)`, `Abs(kg)`, `Bodyweight`; `rebuild` uses only `Pct` and `Bodyweight`. - Thirteen weeks: the seeded week, then three blocks of three work weeks and a deload. `Program.week()` cycles the body after that. - Main lifts run the DUP sheet's wave: 5RM / 1RM / 3RM top sets at 85 / 95 / 90 % of TM with 4x12 / 4x4 / 4x8 backoffs at 65 / 85 / 75 %, the two days of a twice-weekly lift phase-offset, and +1.5 / +1.0 points per block as template progression. The sheet's percentages were of a true 1RM; against a TM of 0.9 × e1RM they start about ten percent more conservative, deliberately, and `tm_progress` closes the gap. - Accessories are a rep range at a fraction of the lift's own e1RM with the last set AMRAP; `e1rm_refresh` is their progression. - Every set carries a rest (`Sets.rest_s`: 3:00 after a top set, 2:00 backoffs, 1:15 accessories) and every top-set slot a warm-up ramp (`Slot.warmup`: 50 / 70 / 85 % of the top set for 8 / 5 / 3), Liftoscript's `warmup:` with percentages of the first working set. The renderer rounds the ramp on the lift's step and drops a rung that rounds to nothing, repeats the previous one or reaches the working load. Logged as warm-up sets in Strong they are invisible to every rule. ### Initial rule set (all explicit, each emits an Adjustment with evidence) This table matches `src/onemore/rules/`, in `DEFAULT_RULES` order (`rules/__init__.py`); it replaces the design-time sketch this section originally held. `calibrate_tm` and `calibrate_new_lift` share one factor, `LiftSpec.tm_factor` (0.9 by default); `tm_progress` is one function emitting four different adjustment names depending on outcome. | Rule | Fires when | Effect | |---|---|---| | `e1rm_refresh` | any lift's best work set in the rolling 21-day window changes its estimated max | sets the lift's rolling e1RM | | `missed_week_repeat` | fewer than 2 of the planned days logged (each session matches at most one planned day: the one whose main lift it reaches first) | repeat the week, do not advance | | `calibrate_tm` | the seeded (calibration) week closes | TM = tm_factor × that week's best e1RM | | `tm_progress` | all top-set reps hit and AMRAP ≥ target + 2 | TM += lift's load step | | `tm_hold` | reps hit, AMRAP < target + 2 | TM unchanged, note why | | `tm_reduce` | top set missed two weeks running; a week whose top-set day was not logged is not judged | TM −10% | | `tm_pain_hold` | top set short of target and the week's Strong note on the lift says it hurt | TM unchanged; not a miss, streak untouched | | `calibrate_new_lift` | a main lift with no TM is trained in a work week (it joined a running plan after the seeded week) | TM = tm_factor × that week's best e1RM, same as `calibrate_tm`; runs after `tm_progress` so the same week is not also judged | | `e1rm_ceiling` | TM > rolling 21-day e1RM | TM clamped to the e1RM (the clamp is 1.0 × e1RM, not 0.9× — § The seeded week and the ceiling) | | `dumbbell_ceiling` | load cannot rise — rack or stack is at its known ceiling and every set there hit the prescribed rep range | +1 rep, then (past `REP_CAP`) converts to +1 set; a TM above the ceiling is clamped down to it | | `accessory_volume` | mean logged RPE on a non-main lift > 9 two weeks running / < 7 two weeks running | −1 / +1 set, bounded; almost always silent — 3 of 2532 logged sets carry an RPE (§ RPE is not logged) | | `deload_after_reductions` | `tm_reduce` cut two or more main lifts' TM in the same week | inserts a deload before continuing | | `recovery_deload` | Apple Health resting heart rate: this week's mean sits `ONEMORE_RHR_THRESHOLD` (default +5.0 bpm, measured) above the 28-day mean | inserts a deload; silent before week 4, in a deload week, or with fewer than 4 daily readings in the closing week | ### Rendering a week `onemore plan --week 5` prints per day, per slot: exercise, sets × reps, load rounded on the lift's own step, the rest, the warm-up ramp, the rule notes that changed anything since last week, and the previous session's actuals for that slot. A vitals line (bodyweight with its 7- and 28-day means, resting HR, sleep) heads the week when Health data has landed. The same renderer feeds `/api/week`, which the web app draws (README § Web app). --- ## 4. Repo layout ``` onemore/ pyproject.toml uv, python 3.13, ruff, pytest CLAUDE.md README.md PLAN.md (this file) src/onemore/ model.py canonical dataclasses store.py sqlite, idempotent upsert exercises.py + exercises.toml catalog and per-source aliases sources/ base.py Source / Sink protocols strong_csv.py hevy.py wger.py planned (build order step 5); not written apple_health.py Health Auto Export JSON apple_health_xml.py the Health app's own export zip / export.xml vitals.py latest / 7-day / 28-day summary of the metrics table estimate.py e1RM, training max, RPE table program/ spec.py Program / Week / Day / Slot / Sets / loads render.py per-exercise rounding, text + HTML week programs/rebuild.py the shipped default (retired 2026-09-21, restored 2026-09-28) programs/comeback.py opt-in via ONEMORE_PROGRAM=comeback (§ `rebuild` restored) rules/ base.py Rule protocol, Adjustment, Context (carries the catalog) progression.py volume.py schedule.py ceiling.py engine.py advance() cli.py onemore ingest | plan | status | explain web.py JSON API + webhooks; serves static/ (the app) static/ index.html app.js app.css — no build step, no CDN pale.css the Pale design tokens (docs/design.md); app.css uses only these fonts/ the three faces those tokens name, vendored — the CDN rule wins scripts/ analyses against the real export; never tests derive_increments.py per-exercise load steps from every logged load replay_rules.py replay the rules over the imported history, read-only tests/ fixtures/strong_12col.csv strong_10col.csv hevy_workout.json docker/Dockerfile compose.yml planned (build order step 4); not written data/ gitignored: onemore.db, imports/ (Strong CSVs, the Health zip) ``` ### Build order 1. **Strong import + store + `onemore status`.** Needs one real export from your phone. Validates the CSV variant and gives e1RM history per lift. 2. **Program spec + renderer + calibration week.** `onemore plan --week 1`. 3. **Rules + `explain`.** Replay against the imported history to check the rules fire the way you expect before they touch a live plan. 4. **Webhook ingest + phone page, NAS container.** iOS Shortcut on the phone. 5. **Hevy adapter** when and if a key exists; wger adapter if ever deployed. ### Direction questions — answered 2026-09-06 **What the export showed.** 224 sessions, 2022-05-16 to 2025-01-03, almost entirely machines and dumbbells: leg press, machine shoulder press, machine curls, dumbbell bench, lat pulldown, chest press. Barbell squat, bench, deadlift and overhead press do not appear at all; the only squat and deadlift rows are Smith-machine, 16 sets each. 1. **Equipment: Planet Fitness.** No barbells, no squat or power racks, no platforms. Smith machine, selectorized machines, dumbbells (typically to 60–80 lb), cardio. No chalk, no dropping weight. 2. **Starting point: dissolved by 1.** The 2022–2025 history is on this same equipment, so it is representative rather than a gap — it seeds the movements the program will actually use. 3. **Export cadence: still open**, and the only one left. § Client below. 4. **Days per week: 4.** Provisional — "4 sounds fair", not a commitment, so the spec keeps 3 reachable. **Consequence: the draft `rebuild` program is dead.** It is built on barbell squat / bench / deadlift / press, none of which exist at Planet Fitness. The program, the exercise catalog and the `Lift` increments are rebuilt around Smith and selectorized movements. Estimation and the rule engine are unaffected — they only ever read sets, reps, load and RPE. What that changes, concretely: - The main lifts become machines, not the Smith — see § Which movements are main. The Smith bar's tare varies by machine and is a per-branch catalog field, not a rule. - Dumbbells step by the rack (usually 5 lb) and stop where the rack stops; machines step by the pin. Both are catalog data, per exercise, not constants. - `dumbbell_ceiling` is the new rule the equipment forces: when load cannot rise, progress becomes reps and then sets, never a load bump. It fits the existing contract as-is — `Adjustment.field` is a free string, so `sets` is as expressible as `tm`, and the reps-then-sets counter lives in the per-lift state `Context.states` already carries. - e1RM on a Smith or a machine is never compared to a barbell e1RM, only to itself over time. The `e1rm_ceiling` clamp still applies within a lift. ### Client — no native iOS app (decided 2026-09-06) Strong stays the logger; the repo renders the week to a read-only home-screen PWA over a private network (build order step 4). The toolchain is not the reason: EAS Build compiles and signs iOS on hosted macOS workers, so no Mac is needed and there is none in the house. The reasons are that a $75 Hevy lifetime buys the same escape from the weekly export ritual, and the two things native would add — a rest timer that fires with the screen off, and a Watch app — are wants, not needs, for a program that adapts weekly. ### What is built (2026-09-06) - **Verified, tested:** Strong CSV parser (all three header variants plus the `1,301h 4m` unquoted-thousands duration row seen in the real export), SQLite store with idempotent import, e1RM / RPE-table estimation. - **Equipment-correct and tested (2026-09-06, second pass):** the exercise catalog, per-exercise load steps and rounding, `dumbbell_ceiling`, and the engine's guard against unpersisted rule state. - **The `rebuild` program (2026-09-06, third pass; retired 2026-09-21):** machine main lifts, the DUP wave and block creep, no RPE anywhere, and a seeded first week — § The program as written. Tested for shape (`tests/test_rebuild.py`), and the progression rules and `advance()` now have tests of their own (`tests/test_progression.py`, `tests/test_engine.py`), which is what found the three defects in § The seeded week and the ceiling. `onemore plan` still warns about any prescribed lift the gym does not have; for `rebuild` the list is empty. - **Rest, warm-ups and Apple Health (2026-09-06, fourth pass):** rest and ramps in the spec and renderer; the Health Auto Export adapter, the native Health export parser, the `metrics` table and the vitals summary, all tested on synthetic exports and the native one run against a real 1.1 GB export (§ What the Health export showed). - **The recovery rule (2026-09-07, sixth pass):** the export cleared the precondition above — 1,784 resting-HR readings over 1,776 days — so `recovery_deload` is written: a week whose mean resting HR sits +5.0 bpm above the 28-day mean sets `deload_next`. The threshold is the 92nd percentile of the weeks actually trained, not of all 274 — measuring against every week, trained or not, made +4.0 look twice as rare as it is (`scripts/recovery_threshold.py`). Sleep and HRV were the obvious inputs and are not usable — the watch is not worn to bed, so HRV is waking spot checks. The rule is silent before week 4, in a deload week, and whenever the closing week holds fewer than four daily readings; the windows are the training week's, so a Health export must land before Advance to be read. - **The web app (2026-09-06, fifth pass):** `web.py` grew from the one phone page into a JSON API over the engine and a static single-page app (README § Web app), tested over HTTP on a synthetic machine-lift fixture (`tests/test_web.py`, `tests/fixtures/strong_machines.csv`) and looked at against the real database at phone and desktop widths in both themes. Writes (import, start, advance) sit behind the same token as the webhooks. - **Not started:** the Hevy / wger adapters (build order step 5). The NAS container and the iOS Shortcut that feeds it are done (`DEPLOY.md`). Nothing in the plan has yet seen a live training week. #### The equipment pass, concretely - **The catalog is the sole owner of load arithmetic.** 64 entries, every one carrying `equipment`, `step_lb` and `available`; all 47 previously unmapped Strong names resolve, so `onemore status --unmapped` is empty against the real export. An import re-resolves the ids of sessions it already holds (`Store.remap_exercise_ids`), or a catalog edit never reaches the history — the import skips a session it has seen, so the ids would stay frozen at whatever the catalog said that day. `LiftSpec.inc_kg` defaults to None meaning "the catalog's step", and `Config.fallback_step_lb` is only what an exercise with no catalog step falls back to — there is no global rounding constant. - **The steps are measured from the export**, not assumed: each is the modal difference between an exercise's consecutive distinct logged loads. Leg press 5 lb, machine curl 2.5, pulldown 10. Method, per-exercise numbers and every merge between two Strong names: `scripts/derive_increments.py`. - **`ceiling_lb` is set only for the dumbbell rack, `smith_tare_lb` nowhere.** A machine's heaviest logged load is a floor on its stack top, not the stack top; Strong logs a Smith's added load, and the bar's counterweight varies by machine. Both are gym errands — read the stack tops, weigh the bar — and until they are done `dumbbell_ceiling` stays silent on machines rather than stalling a lift with room left, and a Smith e1RM compares only to itself. - **`dumbbell_ceiling` adds reps, then a set, never load**, and each rung is earned at the rep range the plan actually printed. It reads the catalog through `Context` (`ctx.step_kg`, `ctx.ceiling_kg`) and writes a `reps_delta` the renderer applies — a rule whose state nothing reads is a plan changed silently. - **A rule may only write state in `engine.PERSISTED_STATE`**; `_apply` raises otherwise, naming the rule and the field. #### Replay against the real history `scripts/replay_rules.py` walks the 224 imported sessions a week at a time and prints every `Adjustment` with its evidence. It is a read-only dry run over an in-memory copy, and it is a script rather than a test because tests use the synthetic fixtures and never the real export. 138 weeks replay without an error. What it showed: - `dumbbell_ceiling` never fires on this history, and should not: the heaviest dumbbell bench ever logged is 50 lb against an 80 lb rack. - `accessory_volume` never fires: 3 of 2532 sets have an RPE. The rule is documented to stay silent without RPE rather than guess, and it does. **This is a live constraint on the program** — every RPE-prescribed accessory depends on RPE actually being logged in Strong. - `missed_week_repeat` fires in all 138 weeks, so nothing downstream is ever reached — hence the script's `--ignore-missed` diagnostic flag. Mostly the replay scaffold, which assigns one main lift per day where the real sessions are machine circuits hitting several at once. Worth a look anyway: the rule counts *distinct planned days whose main lift was trained*, which undercounts a week where one session covers three of them. ### Which movements are main — machines (decided 2026-09-06) The § Direction questions answers pointed at Smith squat / bench / OHP / RDL. The export does not support them: Smith squat 4 sessions / 15 sets / best 60 lb and last touched 2024-04, Smith deadlift 5 sessions all in 2022, Smith bench 1. Against leg press 49 / 165 / 280 lb, machine shoulder press 47, lat pulldown 45, leg curl 44. A main lift needs a load history, because its training max is seeded from that history and then progressed off the top set's reps. On the Smith there is none to seed from, and the stall rules would be firing on noise. So the machines carry the load. **The Smith squat stays as an accessory with its own slow progression** — it is the one pattern the machines do not give, it costs nothing to carry, and `main_lifts()` is program config, so promoting it once it has a history is a one-line change. The objection that machine strength is a leverage artifact does not bite here: `rolling_e1rm` only ever compares a lift to itself, on one machine, and nothing in the engine compares across machines. ### The seeded week and the ceiling (decided 2026-09-06) Writing the layouts without RPE exposed a gap the earlier sections left open: week 1 existed because there is no training max yet, and it was prescribed by RPE because there was nothing else to prescribe by. Now: - **Week 1 is seeded, not calibrated by feel.** `onemore start` runs `seed_from_history` over every lift in the program — accessories too, since they are prescribed off their own e1RM — taking each lift's best historical e1RM at face value inside 180 days and ×0.8 beyond. The last logged session is 2025-01-03, so today everything is decayed. Week 1 prescribes the main lifts at 72 % of that e1RM for a 5+ top set (0.8 × the TM the e1RM implies) and `calibrate_tm` sets the TM at 0.9 × whatever e1RM the top set actually produced. A flat 0.8 for twenty months off is a guess; the point of the AMRAP is that it only has to be a safe one, because the TM is re-derived from the set a week later. - **The e1RM ceiling is the e1RM, not 0.9 of it.** Writing the engine test showed the 0.9 clamp from § Initial rule set undoing every earned step: calibration puts the TM at exactly 0.9 × e1RM, and a 5+ at 85 % of TM that beats its target by two implies, by Epley, an e1RM *below* the one it was prescribed from (85 % of TM is 76.5 % of e1RM, a nine-rep load, so seven reps reads as a worse day). With the clamp at 1.0 the TM has the ten percent of headroom calibration built in and the ceiling fires only when reps fall while the TM climbs — the runaway guard it was meant to be. - **The rolling e1RM really is rolling now.** `e1rm_refresh` documented a 21-day window and read only the week's sets. The engine now hands rules the previous 21 days as `Context.history`, so a week whose top set is a heavy single does not re-derive the max from the single. Rules judging *this week's* performance still read only the week. - **`tm_progress` judges the heaviest prescribed top set.** With two phase-offset days the week holds two AMRAPs at different loads and targets; the heaviest logged set is compared to the heaviest prescribed one's target. A week whose heaviest set sits more than a load step under that prescribed load is a miss whatever its reps — a skipped top set must not let four backoffs of twelve read as progress. - **A set stopped for pain is not a miss (2026-09-21).** `comeback` tells the lifter to end a set when the elbow hurts; counting that as a miss would cut the TM for following the program. The only place a logged set can say why it stopped is Strong's per-exercise note, so `tm_progress` reads it for pain words (`PAIN_WORDS`). First case: week 2's shoulder press, 50 lb x4 against a target of 10, noted "began to hurt", applied as a miss before this existed. ### RPE is not logged, so nothing may read it (decided 2026-09-06) Three of the 2532 imported sets carry an RPE, and the author does not use the term. A program cannot depend on a signal that is not in the data and would be guessed at if demanded, so the dependency goes rather than the habit changing. Everything RPE-prescribed moves to percent-of-TM plus a last-set AMRAP: a rep count, which Strong records on every set, feeding the same progression. Done in `rebuild` (main lifts off TM, accessories off their own e1RM, the seeded week off the decayed historical e1RM). `accessory_volume` stays in the rule set, reads nothing but RPE, and fires zero times across the 138 replayed weeks; it wakes up only if RPE ever gets logged. `Rpe` stays in the DSL — the estimator's RPE table is how a logged RPE becomes an e1RM, and that is still right for the sets that have one. ### `comeback` supersedes `rebuild` (decided 2026-09-06) A left radial head fracture, nonoperative and cleared for a gradual return, makes `rebuild` the wrong program: it prescribes pressing, curls, triceps isolation and a loaded Smith bar without reference to an elbow. `comeback` is the program for that return. The clinical reasoning, the biomechanics with citations and every movement kept or dropped because of them are in `docs/research/radial-head-return.md`; the design note is the module docstring, per the § What not to take convention. In outline: - **Two speeds in one program.** The legs are uninjured, so `leg_press` and `leg_curl` run a real 8/5/3 wave; the four upper machines run one fixed light scheme. `rebuild` couples them and cannot express this. - **The upper lifts are main lifts anyway**, which is not obvious — see below. - **Block 1 is five weeks and ends on a scheduled clinical re-evaluation.** Upper-body block 2 is deliberately unwritten rather than planned across a scheduled reassessment. - No template creep, no maximal singles, no direct arm work, and a printed stop-short-of-lockout note on every press slot. #### The Epley ratchet — a defect in `rebuild`, found while writing this `estimate.e1rm` returns `None` above 12 reps, so the best e1RM a single set at `f × e1RM` can measure is `f × e1RM × 1.4`. That beats the stored e1RM only when **`f > 0.714`**. Every `rebuild` accessory is prescribed at 0.60–0.70, so `e1rm_refresh` can only ratchet those lifts *down* — the load shrinks, lagged by the 21-day best-of window, indefinitely. Only `smith_squat` at 0.75 can rise. Reproduced with `estimate.e1rm` directly; asserted in `tests/test_comeback.py::test_epley_ratchet_floor_is_what_the_docstring_claims`. Consequences, all of which shaped `comeback` (`rebuild` was never fixed; it was retired 2026-09-21 instead): - **Accessories belong at 0.75**, where twelve reps measure 1.05 and eight measure 0.95, so the slot self-corrects instead of decaying. `comeback` uses 0.75 throughout and a test enforces a 0.71 floor. - **An accessory-shaped upper body cannot progress at all**, which is why the upper machines here are main lifts driven by `tm_progress` (rep counts, never Epley) rather than the accessory path that would otherwise be the natural way to hold them back. - **The same arithmetic sets a floor on top-set percentages.** `e1rm_ceiling` clamps the TM to the rolling e1RM, so a top set at `f × TM` with `TM = 0.9 × e1RM` needs `f × 0.9 × (1 + reps/30) > 1`, i.e. `f > 0.79` at 12 reps. The upper top set is 0.82 — a "safer" 0.70 would have decayed the e1RM and let the ceiling eat every earned step, which is § The seeded week and the ceiling recurring at a different percentage. **Safety comes from the calibration seed, not the percentage:** week 1 prescribes the upper lifts at 0.50 of the decayed historical e1RM, and `calibrate_tm` turns whatever that light AMRAP actually does into the TM everything after it hangs off. Verified by driving `advance()` over five synthetic weeks: the TM is monotone non-decreasing at one catalog step per week per lift, and `e1rm_ceiling` fires only as a 1–2 lb trim rather than clawing steps back. Rendered against a copy of the real database; `unavailable()` is empty. ### `rebuild` restored as the shipped default (2026-09-28) The retirement above stood until the public-release plan (`docs/public-release-plan.md` § Phase 3) pointed out that a release whose only program assumes the reader's injury helps nobody. `rebuild` is restored from before 3cdf86d with its accessories moved to 0.75 of their own e1RM — the same fix § The Epley ratchet gave `comeback` — and registered as `SHIPPED_DEFAULT` in `src/onemore/program/programs/__init__.py`. `comeback` is not gone: it stays in `PROGRAMS`, and `ONEMORE_PROGRAM=comeback` (or `--program comeback`, or `onemore start --program comeback`) selects it, including for `onemore serve`'s Start button. A plan already stored under one program name keeps loading under that name either way — `default_program()` only fills in a name when none was given. `rebuild`'s exercise selection is unchanged from the version retired above; only the accessory percentage moved.