Spaces:
Running
Running
| """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. | |
| # --------------------------------------------------------------------------- | |
| 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, | |
| ) | |