///|
/// What-if planning for recovery-aware training decisions.
/// Scenarios are deterministic transformations of a baseline plan and never
/// mutate the caller's sessions, which makes them safe for UI previews.
pub(all) enum TrainingScenarioKind {
ScenarioBaseline
ScenarioRecoveryDay
ScenarioEasyAerobic
ScenarioReducedVolume
ScenarioProgression
ScenarioReturnToHard
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct ScenarioConfig {
easy_intensity : Double
recovery_intensity : Double
volume_reduction : Double
progression_limit : Double
minimum_score_for_hard : Double
forecast_weight : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
pub fn ScenarioConfig::default() -> ScenarioConfig {
{
easy_intensity: 0.45,
recovery_intensity: 0.25,
volume_reduction: 0.35,
progression_limit: 1.10,
minimum_score_for_hard: 70.0,
forecast_weight: 0.35,
}
}
///|
pub(all) struct ScenarioSession {
date : String
duration_minutes : Double
intensity : Double
expected_load : Double
purpose : String
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct ScenarioResult {
kind : TrainingScenarioKind
sessions : Array[ScenarioSession]
projected_load : Double
projected_ratio : Double
intensity_ceiling : Double
recovery_cost : Double
risk_score : Double
suitable : Bool
rationale : String
} derive(FromJson, ToJson, Debug, Eq)
///|
pub(all) struct ScenarioComparison {
baseline : ScenarioResult
alternatives : Array[ScenarioResult]
recommended_index : Int
recommendation : String
} derive(FromJson, ToJson, Debug, Eq)
///|
fn scenario_bound(value : Double, low : Double, high : Double) -> Double {
if value.is_nan() || value.is_inf() {
low
} else {
value.clamp(min=low, max=high)
}
}
///|
pub fn scenario_name(kind : TrainingScenarioKind) -> String {
match kind {
ScenarioBaseline => "baseline"
ScenarioRecoveryDay => "recovery_day"
ScenarioEasyAerobic => "easy_aerobic"
ScenarioReducedVolume => "reduced_volume"
ScenarioProgression => "progression"
ScenarioReturnToHard => "return_to_hard"
}
}
///|
fn scenario_session_load(session : ScenarioSession) -> Double {
session.expected_load.max(session.duration_minutes * session.intensity)
}
///|
pub fn scenario_session(
date : String,
duration_minutes : Double,
intensity : Double,
purpose : String,
) -> ScenarioSession {
let duration = scenario_bound(duration_minutes, 0.0, 1440.0)
let effort = scenario_bound(intensity, 0.0, 1.0)
{
date,
duration_minutes: duration,
intensity: effort,
expected_load: duration * (0.35 + effort) * 1.5,
purpose,
}
}
///|
pub fn scenario_from_workout(session : WorkoutSession) -> ScenarioSession {
scenario_session(
session.date,
session.duration_minutes,
(session.intensity / 10.0).clamp(min=0.0, max=1.0),
"existing workout",
)
}
///|
fn scenario_transform(
sessions : Array[ScenarioSession],
kind : TrainingScenarioKind,
config : ScenarioConfig,
) -> Array[ScenarioSession] {
let result = []
for session in sessions {
let factor = match kind {
ScenarioBaseline => 1.0
ScenarioRecoveryDay => config.recovery_intensity
ScenarioEasyAerobic => config.easy_intensity
ScenarioReducedVolume => 1.0 - config.volume_reduction
ScenarioProgression => (1.0 + config.progression_limit - 1.0).min(1.15)
ScenarioReturnToHard => 1.0
}
let duration_factor = match kind {
ScenarioReducedVolume => 1.0 - config.volume_reduction
ScenarioRecoveryDay => 0.60
_ => 1.0
}
let purpose = match kind {
ScenarioBaseline => session.purpose
ScenarioRecoveryDay => "recovery and mobility"
ScenarioEasyAerobic => "easy aerobic maintenance"
ScenarioReducedVolume => "reduced-volume technique"
ScenarioProgression => "controlled progression"
ScenarioReturnToHard => "return to hard training"
}
result.push(
scenario_session(
session.date,
session.duration_minutes * duration_factor,
session.intensity * factor,
purpose,
),
)
}
result
}
///|
fn scenario_load_average(sessions : Array[ScenarioSession]) -> Double {
mean_value(sessions.map(session => scenario_session_load(session)))
}
///|
fn scenario_total_load(sessions : Array[ScenarioSession]) -> Double {
sum_values(sessions.map(session => scenario_session_load(session)))
}
///|
fn scenario_highest_intensity(sessions : Array[ScenarioSession]) -> Double {
if sessions.length() == 0 {
0.0
} else {
let mut result = 0.0
for session in sessions {
if session.intensity > result {
result = session.intensity
}
}
result
}
}
///|
fn scenario_recovery_cost(sessions : Array[ScenarioSession]) -> Double {
sum_values(
sessions.map(session => session.duration_minutes * session.intensity * 0.85),
)
}
///|
fn scenario_kind_adjustment(
kind : TrainingScenarioKind,
recovery_score : Double,
current_ratio : Double,
config : ScenarioConfig,
) -> Double {
let recovery_factor = if recovery_score <= 0.0 {
0.40
} else {
recovery_score / 100.0
}
let ratio_factor = if current_ratio <= 0.0 {
1.0
} else {
(1.0 / current_ratio).clamp(min=0.35, max=1.0)
}
let kind_factor = match kind {
ScenarioBaseline => 1.0
ScenarioRecoveryDay => 0.20
ScenarioEasyAerobic => 0.35
ScenarioReducedVolume => 0.45
ScenarioProgression => 0.75
ScenarioReturnToHard => 0.90
}
(kind_factor *
(0.65 * recovery_factor + 0.35 * ratio_factor) *
(1.0 - config.forecast_weight * 0.20)).clamp(min=0.0, max=1.0)
}
///|
pub fn simulate_training_scenario(
sessions : Array[ScenarioSession],
kind : TrainingScenarioKind,
recovery_score : Double,
current_ratio : Double,
config : ScenarioConfig,
) -> ScenarioResult {
let transformed = scenario_transform(sessions, kind, config)
let load = scenario_total_load(transformed)
let average = scenario_load_average(transformed)
let ratio = if current_ratio <= 0.0 {
0.0
} else {
current_ratio * load / scenario_total_load(sessions).max(1.0)
}
let ceiling = scenario_highest_intensity(transformed)
let cost = scenario_recovery_cost(transformed)
let adjustment = scenario_kind_adjustment(
kind, recovery_score, current_ratio, config,
)
let risk = (1.0 - adjustment) * 0.80 + ratio.max(1.0) / 3.0 * 0.20
let suitable = recovery_score >= config.minimum_score_for_hard ||
kind == ScenarioRecoveryDay ||
kind == ScenarioEasyAerobic ||
kind == ScenarioReducedVolume
let rationale = match kind {
ScenarioBaseline => "Reference projection without adjustment."
ScenarioRecoveryDay =>
"Reduces intensity and volume to create recovery space."
ScenarioEasyAerobic =>
"Maintains movement while limiting cardiovascular stress."
ScenarioReducedVolume => "Preserves session pattern but removes volume."
ScenarioProgression =>
"Adds a small dose only when recovery and load support it."
ScenarioReturnToHard =>
"Restores hard work after recovery clears the configured floor."
}
{
kind,
sessions: transformed,
projected_load: load,
projected_ratio: ratio,
intensity_ceiling: ceiling,
recovery_cost: cost,
risk_score: scenario_bound(risk, 0.0, 1.0),
suitable,
rationale: "\{rationale} average_load=\{average.to_string()}",
}
}
///|
pub fn scenario_from_plan(
plan : TrainingLoadPlan,
recovery_score : Double,
kind : TrainingScenarioKind,
) -> ScenarioResult {
let sessions = []
for day in plan.days {
for entry in day.entries {
sessions.push(
scenario_session(
entry.date,
entry.duration_minutes,
entry.rpe / 10.0,
load_band_name(entry.band),
),
)
}
}
simulate_training_scenario(
sessions,
kind,
recovery_score,
plan.profile.acute_chronic_ratio,
ScenarioConfig::default(),
)
}
///|
pub fn compare_training_scenarios(
plan : TrainingLoadPlan,
recovery_score : Double,
config : ScenarioConfig,
) -> ScenarioComparison {
let baseline = scenario_from_plan(plan, recovery_score, ScenarioBaseline)
let kinds = [
ScenarioRecoveryDay,
ScenarioEasyAerobic,
ScenarioReducedVolume,
ScenarioProgression,
ScenarioReturnToHard,
]
let alternatives = []
for kind in kinds {
alternatives.push(
simulate_training_scenario(
baseline.sessions,
kind,
recovery_score,
plan.profile.acute_chronic_ratio,
config,
),
)
}
let mut recommended = 0
let mut best_risk = 2.0
for i in 0.. Array[Double] {
let result = [
comparison.baseline.projected_load,
comparison.baseline.projected_ratio,
comparison.baseline.risk_score,
comparison.recommended_index.to_double(),
]
for scenario in comparison.alternatives {
result.push(scenario.projected_load)
result.push(scenario.projected_ratio)
result.push(scenario.risk_score)
result.push(scenario.intensity_ceiling)
}
result
}
///|
pub fn scenario_comparison_csv(comparison : ScenarioComparison) -> String {
let grid = [
[
"scenario", "projected_load", "projected_ratio", "intensity_ceiling", "recovery_cost",
"risk_score", "suitable", "rationale",
],
]
let all = [comparison.baseline]
for scenario in comparison.alternatives {
all.push(scenario)
}
for scenario in all {
grid.push([
scenario_name(scenario.kind),
scenario.projected_load.to_string(),
scenario.projected_ratio.to_string(),
scenario.intensity_ceiling.to_string(),
scenario.recovery_cost.to_string(),
scenario.risk_score.to_string(),
scenario.suitable.to_string(),
scenario.rationale,
])
}
to_csv(grid)
}
///|
pub fn scenario_recommended(comparison : ScenarioComparison) -> ScenarioResult {
if comparison.alternatives.length() == 0 {
comparison.baseline
} else {
comparison.alternatives[comparison.recommended_index.clamp(
min=0,
max=comparison.alternatives.length() - 1,
)]
}
}
///|
pub fn scenario_requires_recovery(result : ScenarioResult) -> Bool {
result.risk_score >= 0.65 || result.projected_ratio >= 1.30
}
///|
pub fn scenario_is_lower_load(
candidate : ScenarioResult,
baseline : ScenarioResult,
) -> Bool {
candidate.projected_load <= baseline.projected_load &&
candidate.risk_score <= baseline.risk_score
}
///|
pub fn scenario_plan_message(comparison : ScenarioComparison) -> String {
let selected = scenario_recommended(comparison)
if scenario_requires_recovery(selected) {
"Selected scenario remains high stress; add a recovery checkpoint."
} else {
comparison.recommendation
}
}