Packages

Reference Elixir validator for WPL (Wellness Plan Language) — JSON Schema + semantic invariants

Current section

Files

Jump to
wpl_validator lib wpl enforce.ex
Raw

lib/wpl/enforce.ex

defmodule WPL.Enforce do
@moduledoc """
Pass-3 enforcement: evaluate personalization rules against a ClientContext
and strip forbidden activities from a compiled WPL plan.
Ported from wpl-validator-ts/src/enforce/index.ts. Pure function, no
process state. Maps are immutable in Elixir so "deep clone" is structural
copy via `Jason.decode!(Jason.encode!(plan_json))` for round-trip purity,
matching the TS `JSON.parse(JSON.stringify(...))` behavior.
"""
alias WPL.Enforce.{Cycle, Matcher, RuleEvaluator}
@applicable_actions MapSet.new(["forbid_exercise"])
@type client_context :: map()
@type rule :: map()
@type enforce_opts :: [plan_start_date: String.t()]
@type enforcement_result :: %{
plan: map(),
evaluated_rules: [map()],
stripped: [map()],
diagnostics: [map()]
}
@doc """
Evaluate `rules` against `ctx` and strip forbidden activities from `plan_json`.
Returns `%{plan, evaluated_rules, stripped, diagnostics}`.
Options:
- `:plan_start_date` — ISO date string of plan day 1 (required for cycle_day-conditioned rules).
"""
@spec enforce(map(), client_context(), [rule()], enforce_opts()) :: enforcement_result()
def enforce(plan_json, ctx, rules, opts \\ []) do
diagnostics = []
stripped = []
static_eval = RuleEvaluator.evaluate_rules(rules, ctx)
diagnostics = diagnostics ++ static_eval.diagnostics
# Emit diagnostics for non-applicable action types in firing rules
non_applicable_diags =
Enum.flat_map(static_eval.evaluated, fn r ->
Enum.flat_map(r.actions, fn a ->
type = a["type"] || a[:type] || ""
if not MapSet.member?(@applicable_actions, type) do
[
%{
code: "UNKNOWN_ACTION_TYPE",
rule_id: r.rule_id,
message:
"action type '#{type}' has no enforcement applicator yet — it is reported but not applied",
meta: %{action_type: type}
}
]
else
[]
end
end)
end)
diagnostics = diagnostics ++ non_applicable_diags
static_forbids = forbidden_exercises(tag_actions(static_eval.evaluated))
# Deep clone via JSON round-trip (matches TS JSON.parse/JSON.stringify)
clone = plan_json |> Jason.encode!() |> Jason.decode!()
inner_plan = Map.get(clone, "plan")
if not is_map(inner_plan) do
%{
plan: clone,
evaluated_rules: static_eval.evaluated,
stripped: stripped,
diagnostics: diagnostics
}
else
plan_start_date = Keyword.get(opts, :plan_start_date)
uses_cycle =
not is_nil(ctx[:cycle] || Map.get(ctx, "cycle")) and not is_nil(plan_start_date)
{new_plan, stripped, diagnostics} =
walk_phases(
inner_plan,
ctx,
rules,
static_forbids,
uses_cycle,
plan_start_date,
stripped,
diagnostics
)
final_clone = Map.put(clone, "plan", new_plan)
%{
plan: final_clone,
evaluated_rules: static_eval.evaluated,
stripped: stripped,
diagnostics: diagnostics
}
end
end
defp tag_actions(evaluated) do
Enum.flat_map(evaluated, fn r ->
if r.condition_met do
Enum.map(r.actions, fn a -> Map.put(a, "__rule_id", r.rule_id) end)
else
[]
end
end)
end
defp forbidden_exercises(actions) do
Enum.reduce(actions, %{}, fn a, acc ->
type = a["type"] || a[:type]
exercise = a["exercise"] || a[:exercise]
rule_id = a["__rule_id"] || "unknown_rule"
if type == "forbid_exercise" and is_binary(exercise) and not Map.has_key?(acc, exercise) do
Map.put(acc, exercise, rule_id)
else
acc
end
end)
end
defp activity_name(act) do
cond do
is_binary(act["exercise_ref"]) -> act["exercise_ref"]
is_binary(act["name"]) -> act["name"]
true -> ""
end
end
defp match_forbid(name, forbids) do
if name == "" do
nil
else
Enum.find_value(forbids, fn {pattern, rule_id} ->
if Matcher.collides(name, pattern), do: rule_id, else: nil
end)
end
end
defp walk_phases(
inner_plan,
ctx,
rules,
static_forbids,
uses_cycle,
plan_start_date,
stripped,
diagnostics
) do
phases = inner_plan["phases"] || []
{new_phases, stripped, diagnostics, _weeks_before} =
phases
|> Enum.with_index()
|> Enum.reduce(
{[], stripped, diagnostics, 0},
fn {phase, _pi}, {acc_phases, acc_stripped, acc_diags, weeks_before} ->
weeks = phase["weeks"] || []
{new_weeks, acc_stripped, acc_diags} =
weeks
|> Enum.with_index()
|> Enum.reduce(
{[], acc_stripped, acc_diags},
fn {week, wi}, {acc_weeks, acc_s, acc_d} ->
week_order =
if is_number(week["order"]), do: trunc(week["order"]), else: wi + 1
days = week["days"] || []
{new_days, acc_s, acc_d} =
days
|> Enum.with_index()
|> Enum.reduce(
{[], acc_s, acc_d},
fn {day, di}, {acc_days, s, d} ->
forbids =
if uses_cycle do
dow = Cycle.day_of_week_offset(day["day_of_week"])
if not is_nil(dow) and not is_nil(plan_start_date) do
date =
Cycle.day_date_for_plan_position(
plan_start_date,
weeks_before,
week_order,
dow
)
cycle = ctx[:cycle] || Map.get(ctx, "cycle")
if is_map(cycle) do
cd = Cycle.compute_cycle_day(date, cycle)
day_eval =
RuleEvaluator.evaluate_rules(rules, Map.put(ctx, :cycle_day, cd))
day_forbids = forbidden_exercises(tag_actions(day_eval.evaluated))
Map.merge(static_forbids, day_forbids)
else
static_forbids
end
else
static_forbids
end
else
static_forbids
end
if map_size(forbids) == 0 do
{[day | acc_days], s, d}
else
blocks = day["blocks"] || []
# phase_idx is the 0-based input index; length(acc_phases) equals
# the number of phases already processed, which is the same as _pi.
phase_idx = length(acc_phases)
{new_blocks, s, d} =
blocks
|> Enum.with_index()
|> Enum.reduce(
{[], s, d},
fn {block, bi}, {acc_blocks, bs, bd} ->
activities = block["activities"] || []
{kept, bs, bd} =
activities
|> Enum.with_index()
|> Enum.reduce(
{[], bs, bd},
fn {act, ai}, {k, ks, kd} ->
name = activity_name(act)
matched_rule = match_forbid(name, forbids)
if is_nil(matched_rule) do
{[act | k], ks, kd}
else
path =
"/plan/phases/#{phase_idx}/weeks/#{wi}/days/#{di}/blocks/#{bi}/activities/#{ai}"
entry = %{
exercise: name,
matched_rule: matched_rule,
path: path
}
{k, [entry | ks], kd}
end
end
)
new_block = Map.put(block, "activities", Enum.reverse(kept))
{[new_block | acc_blocks], bs, bd}
end
)
new_day = Map.put(day, "blocks", Enum.reverse(new_blocks))
{[new_day | acc_days], s, d}
end
end
)
new_week = Map.put(week, "days", Enum.reverse(new_days))
{[new_week | acc_weeks], acc_s, acc_d}
end
)
new_phase = Map.put(phase, "weeks", Enum.reverse(new_weeks))
{[new_phase | acc_phases], acc_stripped, acc_diags, weeks_before + length(weeks)}
end
)
new_inner = Map.put(inner_plan, "phases", Enum.reverse(new_phases))
{new_inner, Enum.reverse(stripped), diagnostics}
end
end