roverdevkit / tests /test_rediscovery_report.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
12.4 kB
"""Tests for the rediscovery orchestration and artifact writer.
Three groups:
1. **Failure capture.** A per-rover RuntimeError does not abort the
sweep; it lands in ``RediscoveryRunSummary.failures``.
2. **Aggregation.** ``summarize_results`` produces the expected columns
and per-rover content from a real (smoke-budget) sweep.
3. **Artifact writer.** ``write_loo_artifacts`` emits the documented
file set and the markdown rollup contains the methodology + the
per-rover table.
"""
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
import pytest
from roverdevkit.validation.rediscovery_report import (
DEFAULT_PER_ROVER_OVERRIDES,
RediscoveryRunSummary,
run_rediscovery_loo,
summarize_results,
write_loo_artifacts,
)
from roverdevkit.validation.rover_rediscovery import (
rediscover_all,
rediscover_ensemble,
)
# ---------------------------------------------------------------------------
# A smoke-budget rediscovery sweep cached once for the whole module.
#
# Only Pragyan is included (subset via per_rover_overrides on every
# *other* rover... actually we just restrict via flown_only and the
# default flown registry happens to be Pragyan + Yutu-2). For test
# headroom we widen mass_ceiling_slop on both to 0.20 and run at
# pop=24 / gen=4 - same budget used in test_rover_rediscovery.py.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def loo_smoke_summary() -> RediscoveryRunSummary:
return run_rediscovery_loo(
flown_only=True,
seed=0,
default_population_size=24,
default_n_generations=4,
default_mass_ceiling_slop=0.20,
per_rover_overrides={},
)
# ---------------------------------------------------------------------------
# Group 0: defaults and rediscover_all override plumbing
# ---------------------------------------------------------------------------
def test_default_per_rover_overrides_records_cadre_budget() -> None:
"""CADRE-unit has documented overrides for ultra-micro feasibility."""
assert "CADRE-unit" in DEFAULT_PER_ROVER_OVERRIDES
cadre = DEFAULT_PER_ROVER_OVERRIDES["CADRE-unit"]
assert cadre["population_size"] == 80
assert cadre["n_generations"] == 12
assert cadre["mass_ceiling_slop"] == 0.50
def test_rediscover_all_rejects_unknown_override_keys() -> None:
"""rediscover_all guards against typo-driven silent param drops."""
with pytest.raises(KeyError, match="unknown keys"):
rediscover_all(
flown_only=True,
population_size=8,
n_generations=2,
per_rover_overrides={"Pragyan": {"nonsense_param": 1}},
)
# ---------------------------------------------------------------------------
# Group 1: failure capture
# ---------------------------------------------------------------------------
def test_failure_capture_does_not_abort_sweep() -> None:
"""An impossible mass-ceiling forces a per-rover RuntimeError; the
sweep should keep going and land the failure in ``failures``.
We force the failure by setting ``mass_ceiling_slop = -0.99`` (mass
budget at 1 % of modelled mass; every individual is infeasible).
"""
summary = run_rediscovery_loo(
flown_only=True,
seed=0,
default_population_size=8,
default_n_generations=2,
default_mass_ceiling_slop=-0.99,
per_rover_overrides={},
)
assert summary.results == []
assert set(summary.failures) == {"Pragyan", "Yutu-2"}
assert not summary.all_succeeded
def test_failure_summary_preserves_kwargs_snapshot() -> None:
summary = run_rediscovery_loo(
flown_only=True,
seed=0,
default_population_size=8,
default_n_generations=2,
default_mass_ceiling_slop=-0.99,
per_rover_overrides={},
)
assert summary.default_kwargs == {
"population_size": 8,
"n_generations": 2,
"mass_ceiling_slop": -0.99,
"seed": 0,
"n_seeds": 1,
"backend": "evaluator",
"evaluator_eval_cap": 1000,
}
assert summary.per_rover_overrides == {}
# ---------------------------------------------------------------------------
# Group 2: aggregation
# ---------------------------------------------------------------------------
_EXPECTED_SUMMARY_COLUMNS: set[str] = {
"rover_name",
"is_flown",
"class_generic_scenario",
"mass_modelled_kg",
"mass_budget_kg",
"pareto_front_size",
"design_space_distance",
"pareto_dominated",
"abs_err_median_pct",
"abs_err_max_pct",
"abs_err_max_var",
"n_wheels_matches",
"grouser_count_matches",
"population_size",
"n_generations",
"mass_ceiling_slop",
}
def test_summarize_results_columns(loo_smoke_summary: RediscoveryRunSummary) -> None:
df = summarize_results(loo_smoke_summary)
assert set(df.columns) == _EXPECTED_SUMMARY_COLUMNS
def test_summarize_results_one_row_per_success(
loo_smoke_summary: RediscoveryRunSummary,
) -> None:
df = summarize_results(loo_smoke_summary)
assert len(df) == len(loo_smoke_summary.results)
assert set(df["rover_name"]) == {r.rover_name for r in loo_smoke_summary.results}
def test_summarize_results_design_space_distance_nonneg(
loo_smoke_summary: RediscoveryRunSummary,
) -> None:
df = summarize_results(loo_smoke_summary)
assert (df["design_space_distance"] >= 0.0).all()
def test_summarize_results_records_per_rover_budget(
loo_smoke_summary: RediscoveryRunSummary,
) -> None:
df = summarize_results(loo_smoke_summary)
assert (df["population_size"] == 24).all()
assert (df["n_generations"] == 4).all()
for slop in df["mass_ceiling_slop"]:
assert slop == pytest.approx(0.20)
def test_summarize_results_abs_err_max_var_is_a_real_variable(
loo_smoke_summary: RediscoveryRunSummary,
) -> None:
valid_vars = {
"wheel_radius_m",
"wheel_width_m",
"grouser_height_m",
"chassis_mass_kg",
"wheelbase_m",
"solar_area_m2",
"battery_capacity_wh",
"avionics_power_w",
"peak_wheel_torque_nm",
}
df = summarize_results(loo_smoke_summary)
assert set(df["abs_err_max_var"]) <= valid_vars
# ---------------------------------------------------------------------------
# Group 3: artifact writer
# ---------------------------------------------------------------------------
def test_write_loo_artifacts_emits_documented_files(
loo_smoke_summary: RediscoveryRunSummary,
tmp_path: Path,
) -> None:
written = write_loo_artifacts(loo_smoke_summary, tmp_path)
assert "summary" in written
assert "failures" in written
assert "report" in written
for r in loo_smoke_summary.results:
slug = r.rover_name.lower().replace("-", "_")
assert slug in written
for name, path in written.items():
assert path.exists(), f"missing artifact {name}: {path}"
def test_write_loo_artifacts_csv_loads_as_dataframe(
loo_smoke_summary: RediscoveryRunSummary,
tmp_path: Path,
) -> None:
written = write_loo_artifacts(loo_smoke_summary, tmp_path)
df = pd.read_csv(written["summary"])
assert set(df.columns) == _EXPECTED_SUMMARY_COLUMNS
assert len(df) == len(loo_smoke_summary.results)
def test_write_loo_artifacts_per_rover_json_round_trips(
loo_smoke_summary: RediscoveryRunSummary,
tmp_path: Path,
) -> None:
"""Every per-rover JSON loads back as a dict with the expected keys."""
written = write_loo_artifacts(loo_smoke_summary, tmp_path)
for r in loo_smoke_summary.results:
slug = r.rover_name.lower().replace("-", "_")
payload = json.loads(written[slug].read_text())
assert payload["rover_name"] == r.rover_name
assert payload["class_generic_scenario"] == r.class_generic_scenario
assert payload["design_space_distance"] == pytest.approx(r.design_space_distance)
assert "pareto_front" in payload
assert len(payload["pareto_front"]) == len(
r.optimization_result.design_vectors
)
def test_write_loo_artifacts_failures_json_always_written(
loo_smoke_summary: RediscoveryRunSummary,
tmp_path: Path,
) -> None:
"""failures.json exists even when every rover succeeded."""
written = write_loo_artifacts(loo_smoke_summary, tmp_path)
failures = json.loads(written["failures"].read_text())
assert failures == loo_smoke_summary.failures
def test_write_loo_artifacts_markdown_has_methodology_section(
loo_smoke_summary: RediscoveryRunSummary,
tmp_path: Path,
) -> None:
written = write_loo_artifacts(loo_smoke_summary, tmp_path)
md = written["report"].read_text()
assert "Layer-5 rediscovery validation" in md
assert "Methodology" in md
assert "Per-rover results" in md
if loo_smoke_summary.results:
assert "Aggregate statistics" in md
for r in loo_smoke_summary.results:
assert r.rover_name in md
# ---------------------------------------------------------------------------
# Group 4: rediscover_ensemble and ensemble-aware run_rediscovery_loo
# ---------------------------------------------------------------------------
def test_rediscover_ensemble_merges_seeds_and_tightens_distance() -> None:
"""Union of N seeds' fronts has min distance <= any single seed's min."""
single = rediscover_ensemble(
"Pragyan",
population_size=24,
n_generations=4,
mass_ceiling_slop=0.20,
n_seeds=1,
base_seed=0,
evaluator_eval_cap=200,
)
ensemble = rediscover_ensemble(
"Pragyan",
population_size=24,
n_generations=4,
mass_ceiling_slop=0.20,
n_seeds=3,
base_seed=0,
evaluator_eval_cap=200,
)
assert ensemble.design_space_distance <= single.design_space_distance + 1e-9
assert len(ensemble.optimization_result.design_vectors) >= len(
single.optimization_result.design_vectors
)
def test_rediscover_ensemble_rejects_zero_seeds() -> None:
with pytest.raises(ValueError, match="n_seeds"):
rediscover_ensemble(
"Pragyan",
n_seeds=0,
population_size=8,
n_generations=2,
evaluator_eval_cap=200,
)
def test_rediscover_ensemble_propagates_total_failure() -> None:
"""When every seed fails, raise a RuntimeError that surfaces the last reason."""
with pytest.raises(RuntimeError, match="every NSGA-II seed failed"):
rediscover_ensemble(
"Pragyan",
n_seeds=2,
population_size=8,
n_generations=2,
mass_ceiling_slop=-0.99,
evaluator_eval_cap=200,
)
def test_run_rediscovery_loo_routes_through_ensemble_when_multi_seed() -> None:
"""n_seeds > 1 should land in summary.default_kwargs and the merged front."""
summary = run_rediscovery_loo(
flown_only=True,
seed=0,
default_population_size=16,
default_n_generations=3,
default_mass_ceiling_slop=0.20,
per_rover_overrides={},
n_seeds=2,
)
assert summary.default_kwargs["n_seeds"] == 2
assert summary.default_kwargs["backend"] == "evaluator"
# Ensemble fronts are the concatenated union; with two seeds we should
# see roughly double the population's worth of points compared with a
# single seed at the same hyperparameters, modulo Pareto filtering
# inside each seed.
for r in summary.results:
assert len(r.optimization_result.design_vectors) >= 1
def test_run_rediscovery_loo_surrogate_backend_requires_bundles() -> None:
"""The surrogate backend without bundles is a programmer error, not a
per-rover feasibility failure. The constructor's ValueError must
propagate (not be silently swallowed into ``summary.failures``)."""
with pytest.raises(ValueError, match="surrogate backend requires"):
run_rediscovery_loo(
flown_only=True,
seed=0,
default_population_size=16,
default_n_generations=3,
default_mass_ceiling_slop=0.20,
per_rover_overrides={},
backend="surrogate",
bundles=None,
)