From 7630934bcb27fb8573aa0c91ff286d5558a87f6d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 9 Apr 2026 16:15:01 -0500 Subject: [PATCH] Accept CamelCase feature names in mix backtest mix backtest --feature NaiveGradient now resolves to the same function as --feature naive_gradient. Names are normalized via Macro.underscore, so any casing works. Also prints the available functions when an unknown feature is requested so typos don't produce an opaque UndefinedFunctionError deep in the Enum.map stack. --- lib/mix/tasks/backtest.ex | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/mix/tasks/backtest.ex b/lib/mix/tasks/backtest.ex index 0ff2efae..f1b1e9e4 100644 --- a/lib/mix/tasks/backtest.ex +++ b/lib/mix/tasks/backtest.ex @@ -7,14 +7,18 @@ defmodule Mix.Tasks.Backtest do ## Usage - mix backtest --feature Microwaveprop.Backtest.Features.naive_gradient mix backtest --feature naive_gradient + mix backtest --feature NaiveGradient # CamelCase also works + mix backtest --feature Microwaveprop.Backtest.Features.naive_gradient mix backtest --feature naive_gradient --sample 1000 --out priv/backtest_reports/naive.md ## Options * `--feature` (required) — fully-qualified `Module.function` or a - short name that lives on `Microwaveprop.Backtest.Features`. + short name that lives on `Microwaveprop.Backtest.Features`. Names + are normalized via `Macro.underscore/1`, so `NaiveGradient`, + `naive_gradient`, and `naiveGradient` all resolve to the same + function. * `--sample` — max number of QSOs to evaluate (default: 5000). * `--baseline` — random-baseline sample size (default: same as `--sample`). * `--out` — optional file path to write the report to in addition @@ -66,16 +70,36 @@ defmodule Mix.Tasks.Backtest do defp resolve_feature(spec) do case String.split(spec, ".") do [name] -> - fun = String.to_atom(name) + fun = resolve_function_atom!(Microwaveprop.Backtest.Features, name) feature_fun = &apply(Microwaveprop.Backtest.Features, fun, [&1, &2, &3]) - {feature_fun, "Microwaveprop.Backtest.Features.#{name}"} + {feature_fun, "Microwaveprop.Backtest.Features.#{fun}"} parts -> {fun_name, mod_parts} = List.pop_at(parts, -1) module = Module.concat(mod_parts) - fun = String.to_atom(fun_name) + fun = resolve_function_atom!(module, fun_name) feature_fun = &apply(module, fun, [&1, &2, &3]) - {feature_fun, Enum.join(parts, ".")} + {feature_fun, "#{inspect(module)}.#{fun}"} + end + end + + # Accept both `naive_gradient` and `NaiveGradient` and anything in between. + defp resolve_function_atom!(module, name) do + Code.ensure_loaded!(module) + normalized = Macro.underscore(name) + fun = String.to_atom(normalized) + + if function_exported?(module, fun, 3) do + fun + else + exported = + module.__info__(:functions) + |> Enum.filter(fn {_f, arity} -> arity == 3 end) + |> Enum.map_join(", ", fn {f, _} -> to_string(f) end) + + Mix.raise( + "Feature #{inspect(module)}.#{normalized}/3 is not defined.\nAvailable 3-arity functions: #{exported}" + ) end end end