- CacheTest: fix sweep timing race by using negative TTL (-1) instead of positive TTL (1) for already-expired entries - ScoreCache: replace ETS match-spec DateTime comparisons with :ets.foldl + DateTime.compare — DateTime structs are maps in Elixir >= 1.15 and ETS can't compare maps with :< / :> guards - Accounts: drop unsupported returning: true on delete_all, return [] for expired tokens list - Backtest: catch ArgumentError from String.to_existing_atom for unknown feature names, preserving the helpful Mix.Error - ContactLive IndexTest: invalidate monthly_bars cache before assertion so test data is visible - RoverLocationsLive MapTest: invalidate cached points before assertion - StatusLiveTest: add DB cleanup in setup to reduce test interference; parameterize NARR candidate coordinates
145 lines
4.5 KiB
Elixir
145 lines
4.5 KiB
Elixir
defmodule Mix.Tasks.Backtest do
|
|
@shortdoc "Evaluate a propagation feature against the QSO corpus"
|
|
@moduledoc """
|
|
Runs `Microwaveprop.Backtest.evaluate/2` (plus the distance and band
|
|
breakdowns) for a named feature function and prints a Markdown report
|
|
to stdout.
|
|
|
|
## Usage
|
|
|
|
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
|
|
mix backtest --all --out priv/backtest_reports/consolidated.md
|
|
|
|
## Options
|
|
|
|
* `--feature` — fully-qualified `Module.function` or a 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.
|
|
* `--all` — run all registered features and produce a consolidated
|
|
pass/fail table.
|
|
* `--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
|
|
to printing it.
|
|
"""
|
|
use Mix.Task
|
|
|
|
alias Microwaveprop.Backtest
|
|
alias Microwaveprop.Backtest.Features
|
|
|
|
@impl Mix.Task
|
|
def run(argv) do
|
|
Mix.Task.run("app.start")
|
|
_ = Oban.pause_all_queues(Oban)
|
|
|
|
{opts, _, _} =
|
|
OptionParser.parse(argv,
|
|
switches: [feature: :string, all: :boolean, sample: :integer, baseline: :integer, out: :string]
|
|
)
|
|
|
|
if Keyword.get(opts, :all) do
|
|
run_all(opts)
|
|
else
|
|
run_single(opts)
|
|
end
|
|
end
|
|
|
|
defp run_all(opts) do
|
|
sample_size = Keyword.get(opts, :sample, 5000)
|
|
baseline_size = Keyword.get(opts, :baseline, sample_size)
|
|
out_path = Keyword.get(opts, :out)
|
|
|
|
features = Features.all_features()
|
|
Mix.shell().info("Running consolidated backtest for #{map_size(features)} features...")
|
|
|
|
results =
|
|
Backtest.consolidated_report(features,
|
|
sample_size: sample_size,
|
|
baseline_size: baseline_size
|
|
)
|
|
|
|
markdown = Backtest.to_consolidated_markdown(results)
|
|
IO.puts(markdown)
|
|
|
|
if out_path do
|
|
File.mkdir_p!(Path.dirname(out_path))
|
|
File.write!(out_path, markdown)
|
|
Mix.shell().info("Wrote consolidated report to #{out_path}")
|
|
end
|
|
end
|
|
|
|
defp run_single(opts) do
|
|
feature_spec = Keyword.fetch!(opts, :feature)
|
|
sample_size = Keyword.get(opts, :sample, 5000)
|
|
baseline_size = Keyword.get(opts, :baseline, sample_size)
|
|
out_path = Keyword.get(opts, :out)
|
|
|
|
{feature_fun, feature_name} = resolve_feature(feature_spec)
|
|
|
|
report =
|
|
Backtest.evaluate(feature_fun,
|
|
sample_size: sample_size,
|
|
baseline_size: baseline_size,
|
|
feature_name: feature_name
|
|
)
|
|
|
|
distance_bins = Backtest.lift_by_distance(feature_fun, sample_size: sample_size)
|
|
band_stats = Backtest.lift_by_band(feature_fun, sample_size: sample_size)
|
|
|
|
markdown =
|
|
Backtest.to_markdown(report, distance_bins: distance_bins, band_stats: band_stats)
|
|
|
|
IO.puts(markdown)
|
|
|
|
if out_path do
|
|
File.mkdir_p!(Path.dirname(out_path))
|
|
File.write!(out_path, markdown)
|
|
Mix.shell().info("Wrote report to #{out_path}")
|
|
end
|
|
end
|
|
|
|
defp resolve_feature(spec) do
|
|
case String.split(spec, ".") do
|
|
[name] ->
|
|
fun = resolve_function_atom!(Features, name)
|
|
feature_fun = &apply(Features, fun, [&1, &2, &3])
|
|
{feature_fun, "Microwaveprop.Backtest.Features.#{fun}"}
|
|
|
|
parts ->
|
|
{fun_name, mod_parts} = List.pop_at(parts, -1)
|
|
module = Module.safe_concat(mod_parts)
|
|
fun = resolve_function_atom!(module, fun_name)
|
|
feature_fun = &apply(module, fun, [&1, &2, &3])
|
|
{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 =
|
|
try do
|
|
String.to_existing_atom(normalized)
|
|
rescue
|
|
ArgumentError -> nil
|
|
end
|
|
|
|
if fun && function_exported?(module, fun, 3) do
|
|
fun
|
|
else
|
|
exported =
|
|
:functions
|
|
|> module.__info__()
|
|
|> 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
|