Authoring a 5/3/1 Cycle, Programmatically
Mix.install([
{:wpl_validator, path: Path.join(__DIR__, "..")},
{:jason, "~> 1.4"},
{:kino, "~> 0.13"}
])
Goal
Build a Wendler 5/3/1 four-week cycle for the squat as a plain Elixir map, validate after each step, and serialize to JSON at the end. This is the shape your code generators / authoring tools should produce.
Building blocks
defmodule SetReps do
@moduledoc "Helpers for assembling exercise prescriptions."
def squat(%{sets: sets, reps: reps, percent_1rm: pct, rest_s: rest}) do
%{
"id" => "ex_squat",
"type" => "exercise",
"exercise_ref" => "back_squat",
"name" => "Back Squat",
# Schema v1.3.0+: muscle-group + movement-pattern tags. Lets a
# consuming app compute weekly volume per muscle group without an
# out-of-band exercise→muscle map.
"primary_muscles" => ["quadriceps", "glutes"],
"secondary_muscles" => ["hamstrings", "spinal_erectors"],
"movement_pattern" => "squat",
"prescription" => %{
"type" => "sets_reps",
"sets" => sets,
"reps" => normalize_reps(reps),
"weight" => %{"type" => "percentage_1rm", "value" => pct, "unit" => "percent"},
"rest" => %{"value" => rest, "unit" => "seconds"},
# Schema v1.2.0+: structured Tempo. 3-1-X-0 = 3s eccentric, 1s
# pause, explosive concentric, 0s pause top.
"tempo" => %{
"eccentric" => 3,
"pause_bottom" => 1,
"concentric" => 0,
"pause_top" => 0,
"explosive_concentric" => true
}
}
}
end
defp normalize_reps(n) when is_integer(n), do: %{"target" => n}
defp normalize_reps({min, max}), do: %{"min" => min, "max" => max}
defp normalize_reps(:amrap_5), do: %{"min" => 5}
end
Week templates
5/3/1 weeks (top set only — accessories omitted to keep this readable):
- W1 — 5/5/5+ at 65/75/85% 1RM
- W2 — 3/3/3+ at 70/80/90% 1RM
- W3 — 5/3/1+ at 75/85/95% 1RM
- W4 — deload 5/5/5 at 40/50/60% 1RM
weeks = [
{1, [{5, 65}, {5, 75}, {:amrap_5, 85}]},
{2, [{3, 70}, {3, 80}, {:amrap_5, 90}]},
{3, [{5, 75}, {3, 85}, {1, 95}]},
{4, [{5, 40}, {5, 50}, {5, 60}]}
]
build_day = fn week_no, sets ->
activities =
sets
|> Enum.with_index(1)
|> Enum.map(fn {{reps, pct}, i} ->
SetReps.squat(%{sets: 1, reps: reps, percent_1rm: pct, rest_s: 180})
|> Map.put("id", "ex_squat_set_#{i}")
end)
%{
"id" => "d_squat_w#{week_no}",
"day_of_week" => 1,
"name" => "Squat — Week #{week_no}",
"type" => "training",
"blocks" => [
%{
"id" => "main",
"type" => "main",
"order" => 1,
"structure" => "straight_sets",
"activities" => activities
}
]
}
end
build_week = fn week_no, sets ->
base = %{
"id" => "wk_#{week_no}",
"name" => "Week #{week_no}",
"order" => week_no,
"days" => [build_day.(week_no, sets)]
}
# Schema v1.2.0+: flag the deload week so downstream tools can render
# a "Deload" badge and analytics can drop volume from week-over-week
# progression curves.
if week_no == 4, do: Map.put(base, "is_deload", true), else: base
end
built_weeks = Enum.map(weeks, fn {n, sets} -> build_week.(n, sets) end)
length(built_weeks)
Assemble the plan
plan = %{
"$schema" => "https://wpl.dev/schemas/wpl/v1.schema.json",
"version" => "1.0.0",
"plan" => %{
"id" => "plan_531_squat_cycle",
"name" => "5/3/1 Squat — 4 Week Cycle",
"description" => "Wendler 5/3/1 main lift cycle for back squat.",
"type" => "workout",
"visibility" => "private",
"metadata" => %{
"tags" => ["531", "strength", "squat"],
"difficulty" => "intermediate",
"estimated_duration_days" => 28,
"language" => "en"
},
"goals" => [
%{
"id" => "goal_531",
"type" => "primary",
"category" => "strength",
"name" => "Increase squat 1RM"
}
],
"phases" => [
%{
"id" => "phase_cycle_1",
"name" => "Cycle 1",
# Schema v1.2.0+: periodization role lets a coach app surface
# "you're in an intensification cycle" without re-deriving it.
"type" => "intensification",
"order" => 1,
"duration" => %{"value" => 4, "unit" => "weeks"},
"weeks" => built_weeks
}
]
}
}
:assembled
Validate
result = WPL.Validator.validate(plan)
case result do
%WPL.Validator.Result{valid?: true, errors: []} ->
Kino.Markdown.new("✅ **Plan is valid** — ready to compile/serialize.")
%WPL.Validator.Result{errors: errors} ->
rows =
Enum.map(errors, fn e ->
%{severity: e.severity, code: e.code, path: e.path, message: e.message}
end)
Kino.DataTable.new(rows)
end
Serialize to JSON
json = Jason.encode!(plan, pretty: true)
Kino.Markdown.new("```json\n" <> json <> "\n```")
What to try next
-
Change a percentage to a string (
"85") and re-run — observe a:schema_violationwithkeyword: type. -
Duplicate an
idbetween two activities and watch a:duplicate_iderror pop up atscope: "day:...". -
Add a phase with
duration: %{"value" => 3, "unit" => "weeks"}but only one week in the array — see the:phase_duration_mismatchwarning (note: warnings don’t make the plan invalid). -
Add
"athlete_thresholds" => %{"body_weight_kg" => 80, "one_rm" => [...]}at the plan level, then express the prescription weight as%{"type" => "percentage_1rm", "value" => 75}— consumers can resolve the absolute load downstream.