Packages

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

Current section

Files

Jump to
wpl_validator lib wpl enforce rule_evaluator.ex
Raw

lib/wpl/enforce/rule_evaluator.ex

defmodule WPL.Enforce.RuleEvaluator do
@moduledoc """
Evaluates WPL personalization rules against a ClientContext.
Ported from wpl-validator-ts/src/enforce/rule-evaluator.ts.
Fail-closed: unknown condition field → UNKNOWN_CONDITION_FIELD diagnostic
(rule evaluates to not-met). Action without string type → UNKNOWN_ACTION_TYPE
diagnostic.
ClientContext is a plain map with atom or string keys. Recognized fields:
weight_kg, height_cm, age, sex, experience, injuries, equipment, fatigue,
goals, cycle_day, cycle.
"""
@known_fields MapSet.new([
"weight",
"weight_kg",
"height",
"height_cm",
"age",
"sex",
"gender",
"experience",
"fitness_level",
"injuries",
"contraindications",
"equipment",
"fatigue",
"goals",
"cycle_day",
"cycle_present"
])
@type rule :: map()
@type client_context :: map()
@type evaluated_rule :: %{
rule_id: String.t(),
condition_met: boolean(),
actions: [map()],
condition: map() | nil
}
@type diagnostic :: %{
code: String.t(),
rule_id: String.t(),
message: String.t(),
meta: map()
}
@spec evaluate_rules([rule()], client_context()) ::
%{evaluated: [evaluated_rule()], diagnostics: [diagnostic()]}
def evaluate_rules(rules, ctx) when is_list(rules) do
diagnostics = []
{evaluated, diagnostics} =
rules
|> Enum.with_index(1)
|> Enum.reduce({[], diagnostics}, fn {rule, idx}, {acc_evaluated, acc_diags} ->
rule_id = Map.get(rule, "id") || Map.get(rule, :id) || "rule_#{idx}"
condition = Map.get(rule, "condition") || Map.get(rule, :condition)
{unknown_diags, condition_met} = evaluate_condition(rule_id, condition, ctx)
actions_raw =
Map.get(rule, "actions") || Map.get(rule, :actions) || []
{actions, action_diags} = evaluate_actions(rule_id, actions_raw)
entry = %{
rule_id: rule_id,
condition_met: condition_met,
actions: actions,
condition: condition
}
{[entry | acc_evaluated], acc_diags ++ unknown_diags ++ action_diags}
end)
%{evaluated: Enum.reverse(evaluated), diagnostics: diagnostics}
end
def evaluate_rules(_rules, ctx), do: evaluate_rules([], ctx)
# Returns {diagnostics, condition_met}
defp evaluate_condition(rule_id, condition, ctx) do
unknown_fields = collect_unknown_fields(condition, rule_id)
met = if unknown_fields == [], do: condition_met?(condition, ctx), else: false
{unknown_fields, met}
end
defp collect_unknown_fields(nil, _rule_id), do: []
defp collect_unknown_fields(condition, rule_id) when is_map(condition) do
if compound?(condition) do
inner = Map.get(condition, "conditions") || Map.get(condition, :conditions) || []
Enum.flat_map(inner, &collect_unknown_fields(&1, rule_id))
else
field = Map.get(condition, "field") || Map.get(condition, :field)
if is_binary(field) and not MapSet.member?(@known_fields, field) do
[
%{
code: "UNKNOWN_CONDITION_FIELD",
rule_id: rule_id,
message:
"condition references field '#{field}' which the enforcement engine cannot resolve — this rule can never fire",
meta: %{field: field}
}
]
else
[]
end
end
end
defp collect_unknown_fields(_condition, _rule_id), do: []
defp condition_met?(nil, _ctx), do: true
defp condition_met?(condition, ctx) when is_map(condition) do
if compound?(condition) do
compound_match(condition, ctx)
else
simple_match(condition, ctx)
end
end
defp condition_met?(_condition, _ctx), do: false
defp compound?(c) do
Map.has_key?(c, "operator") or Map.has_key?(c, :operator) or
Map.has_key?(c, "conditions") or Map.has_key?(c, :conditions)
end
defp compound_match(c, ctx) do
op = Map.get(c, "operator") || Map.get(c, :operator) || "and"
conditions = Map.get(c, "conditions") || Map.get(c, :conditions) || []
if op == "or" do
Enum.any?(conditions, &condition_met?(&1, ctx))
else
Enum.all?(conditions, &condition_met?(&1, ctx))
end
end
defp simple_match(c, ctx) do
op = Map.get(c, "op") || Map.get(c, :op) || "eq"
field = Map.get(c, "field") || Map.get(c, :field)
value = Map.get(c, "value") || Map.get(c, :value)
actual = field_value(field, ctx)
compare(actual, op, value)
end
defp compare(nil, _op, _value), do: false
defp compare(actual, op, value) do
case op do
"eq" ->
stringify(actual) == stringify(value)
"neq" ->
stringify(actual) != stringify(value)
"gt" ->
is_number(actual) and is_number(value) and actual > value
"gte" ->
is_number(actual) and is_number(value) and actual >= value
"lt" ->
is_number(actual) and is_number(value) and actual < value
"lte" ->
is_number(actual) and is_number(value) and actual <= value
"contains" ->
cond do
is_list(actual) -> Enum.any?(actual, &(stringify(&1) == stringify(value)))
is_binary(actual) -> String.contains?(actual, stringify(value) || "")
true -> false
end
"not_contains" ->
cond do
is_list(actual) -> not Enum.any?(actual, &(stringify(&1) == stringify(value)))
is_binary(actual) -> not String.contains?(actual, stringify(value) || "")
true -> false
end
"in" ->
is_list(value) and
Enum.any?(value, &(stringify(&1) == stringify(actual)))
"not_in" ->
is_list(value) and
not Enum.any?(value, &(stringify(&1) == stringify(actual)))
_ ->
false
end
end
defp stringify(nil), do: ""
defp stringify(v) when is_binary(v), do: v
defp stringify(v) when is_number(v), do: to_string(v)
defp stringify(v) when is_boolean(v), do: to_string(v)
defp stringify(v) when is_atom(v), do: Atom.to_string(v)
defp stringify(v), do: to_string(v)
# Field resolution — mirrors the KNOWN_FIELDS set above exactly.
# @known_fields must contain every field name handled here.
defp field_value(field, ctx) do
case field do
f when f in ["weight", "weight_kg"] ->
ctx[:weight_kg] || Map.get(ctx, "weight_kg")
f when f in ["height", "height_cm"] ->
ctx[:height_cm] || Map.get(ctx, "height_cm")
"age" ->
ctx[:age] || Map.get(ctx, "age")
f when f in ["sex", "gender"] ->
ctx[:sex] || Map.get(ctx, "sex")
f when f in ["experience", "fitness_level"] ->
ctx[:experience] || Map.get(ctx, "experience")
f when f in ["injuries", "contraindications"] ->
ctx[:injuries] || Map.get(ctx, "injuries")
"equipment" ->
ctx[:equipment] || Map.get(ctx, "equipment")
"fatigue" ->
ctx[:fatigue] || Map.get(ctx, "fatigue")
"goals" ->
ctx[:goals] || Map.get(ctx, "goals")
"cycle_day" ->
ctx[:cycle_day] || Map.get(ctx, "cycle_day")
"cycle_present" ->
cycle = ctx[:cycle] || Map.get(ctx, "cycle")
if cycle, do: true, else: nil
_ ->
nil
end
end
defp evaluate_actions(rule_id, actions) when is_list(actions) do
Enum.reduce(actions, {[], []}, fn action, {acc_actions, acc_diags} ->
type = action["type"] || action[:type]
if is_binary(type) do
{[normalize_action(action) | acc_actions], acc_diags}
else
diag = %{
code: "UNKNOWN_ACTION_TYPE",
rule_id: rule_id,
message: "action has no string `type`; it cannot be applied and is ignored",
meta: %{action: action}
}
{acc_actions, [diag | acc_diags]}
end
end)
|> then(fn {actions, diags} -> {Enum.reverse(actions), Enum.reverse(diags)} end)
end
defp evaluate_actions(_rule_id, _actions), do: {[], []}
defp normalize_action(action) when is_map(action) do
action
|> Enum.map(fn
{k, v} when is_atom(k) -> {Atom.to_string(k), v}
kv -> kv
end)
|> Map.new()
end
@doc "Return only the actions from rules whose condition was met."
@spec firing_actions([evaluated_rule()]) :: [map()]
def firing_actions(evaluated) do
Enum.flat_map(evaluated, fn r ->
if r.condition_met, do: r.actions, else: []
end)
end
end