# Спецификация 02: микроправка весов — ProgressionRules.EvaluateAfterWorkout Источники: Ф-12.2–12.3, документ метрик правила 3–5, ограничения — решение гейта 1.3 (module_reqs разд. 2.4). ## 1. Сигнатура ```csharp public ProgramVersion? EvaluateAfterWorkout(int athleteId, Guid workoutId) ``` Возврат: созданная draft-версия или null (изменений нет / черновик уже существует). Дедупликация: rules-draft со статусом `draft` и `based_on_version ≥` номера активной версии блокирует новые предложения (покрывает и пользовательские draft поверх активной). ## 2. Входные данные - тренировка `workout` + подходы `workout_set` (репозиторий m01); - предыдущие тренировки атлета (`WorkoutFilter`, по убыванию `started_at`) — для правила 1 («2 тренировки подряд»); - активная версия `ProgramStore.GetActive(athleteId)` — нет → null; - библиотека атлета `IExerciseRepository.Select` (с `ReplacementIds`) — сопоставление и ограничения. Сопоставление план↔факт: `body.exercises[].exercise_id` == `src.exercise.name` (exact, Ordinal); упражнение не найдено → решение не генерируется, пометка в rationale. `workout_set.exercise_id` (uuid) → `src.exercise` → имя → элемент body. ## 3. Решения по упражнениям (проверяются по порядку, срабатывает первое) По каждому упражнению тренировки, присутствующему в активной версии и активному (`is_active=true`): | # | Условие | Действие | |---|---|---| | 1 | в двух последних тренировках **этого упражнения** все плановые подходы `in_plan=true` и `rpe ≤ 7` (rpe во всех подходах указан) | плановый вес `+BaseStepKg` (`is_base=true`) / `+IsoStepKg` (`is_base=false`) | | 2 | в последней тренировке упражнения `rpe ≥ 9` в ≥ 2 подходах | плановый вес `× (1 − BackoffPercent/100)` | | 3 | все плановые подходы закрыты «по плану» и плановые повторения ≥ 15 (верхняя граница диапазона 15–20) | вес `+шаг прогрессии`, плановые повторения → 12 | | 4 | в последней тренировке ≥ 2 подходов с `reps` < плановых | плановый вес `× (1 − BackoffPercent/100)` | | 5 | иначе | без изменения | - «Предыдущая тренировка упражнения» — предыдущая по `started_at` тренировка атлета, содержащая подходы этого упражнения. - Результат веса округляется к 0.5 кг (`Math.Round(x*2)/2`). - Подходы `source=edit` в расчёт не берутся (правка = новая запись, история: берём исходные voice/watch записи). - Вес плана `null` (калибровка) → решение не генерируется. ## 4. Ограничения (module_reqs разд. 2.4) - `is_active=false` → не попадает в draft; если в активной версии сессия состоит только из неактивных — сессия исключается; - у исключаемого упражнения есть активная пара-замена (`ReplacementIds`, замена активна, та же группа) → подставляется замена (имя замены в `exercise_id` body, веса исходного плана, пометка в rationale). Примечание (З-40): «та же группа» гарантируется `exercise_replacement_insert` (проверка на вставке пары); повторная runtime-проверка в `ApplyExclusion` не дублируется; - `body.profile.limitations` переносится без изменений. ## 5. Формирование draft 1. Нет ни одного решения с изменением и ни одного исключения/замены → `null` (черновик не нужен). 2. Deep-copy активного body (JsonNode), применение решений к week 1+ (все недели: v1 ротации нет — одно упражнение = один плановый вес). 3. Дедупликация: существует rules-draft с `based_on_version` == номер активной версии → `null` (черновик не плодится между sync). 4. `ProgramStore.CreateDraft(athleteId, newBody, "rules", rationale)`; rationale — человекочитаемо: по упражнению строка «жим: 100 → 102.5 (+2.5: 2 тренировки подряд закрыты при RPE≤7)», «присед: исключено (is_active=false), замена — жим ногами». 🛡 Активная версия не меняется; активации нет (vision 2.4).