From 1815936a3650e452d056e63b97652b20c7404092 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sat, 26 Sep 2026 00:13:16 -0500 Subject: [PATCH] =?UTF-8?q?scorecard:=20add=20NavScorecard=20reader=20(fro?= =?UTF-8?q?m=5Fdict/from=5Fjson)=20=E2=80=94=20Track=204=20Phase=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scorecard layer could WRITE (to_dict/to_json) but not READ. Add the inverse so a recorded corpus-fitness row loads back into Python — the plumbing a selection/loss stage will read from (nothing consumes it yet; Track 4 Phase 1+ wires it into training). Because to_dict's keys are field-for-field the C++ cvc::nav::scorecard_json keys, the same reader loads a `dbg_arrival_check3 --episodes` / scorecard_json row directly. - NavScorecard.from_dict(d): exact inverse of to_dict across all Track-1 field families (base + formation + coverage + drive + material_time_share + the nested mean_coverage). Tolerant of a partial producer (missing keys keep dataclass defaults) and ignores an embedded "rf" sub-object (the DBG scorecard_json shape) — the base reader is RF-free; the DBG side reads the RF scorecard separately. - NavScorecard.from_json(s): json.loads + from_dict. Tests: a full round-trip (to_dict->from_dict and to_json->from_json reproduce the row exactly, all field families populated) + partial/rf-tolerance. The existing C++<->Python parity gate already pins every aggregate field, so this only adds the reader's own coverage. 13/13 pass; ruff clean. --- grl_snam/scorecard.py | 61 ++++++++++++++++++++++++++++++++++++++++ tests/test_scorecard.py | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/grl_snam/scorecard.py b/grl_snam/scorecard.py index 95df82d..aad3f5e 100644 --- a/grl_snam/scorecard.py +++ b/grl_snam/scorecard.py @@ -217,6 +217,67 @@ def to_dict(self) -> dict: def to_json(self) -> str: return json.dumps(self.to_dict()) + @classmethod + def from_dict(cls, d: dict) -> NavScorecard: + """Parse a scorecard dict back into a ``NavScorecard`` — the inverse of :meth:`to_dict`, and + the reader for the C++ ``cvc::nav::scorecard_json`` output (the keys are field-for-field the + same, so a ``dbg_arrival_check3 --episodes`` / ``scorecard_json`` row loads directly). This is + the plumbing a selection/loss stage reads a recorded corpus fitness from; nothing consumes it + yet (Track 4 Phase 1+ wires it into training). + + Tolerant of a partial producer: any missing key keeps the dataclass default, so an older or + newer writer (or one that omits zero fields) still loads. An ``rf`` sub-object, if present + (the DBG ``scorecard_json`` embeds one), is ignored here — the base reader is RF-free; the DBG + side reads the RF scorecard separately. + """ + cov = d.get("mean_coverage") or {} + return cls( + checkpoint=str(d.get("checkpoint", "")), + n_episodes=int(d.get("n_episodes", 0)), + n_vehicle_runs=int(d.get("n_vehicle_runs", 0)), + success_rate=float(d.get("success_rate", 0.0)), + arrival_rate=float(d.get("arrival_rate", 0.0)), + mean_time_to_goal_s=float(d.get("mean_time_to_goal_s", 0.0)), + p95_time_to_goal_s=float(d.get("p95_time_to_goal_s", 0.0)), + mean_makespan_s=float(d.get("mean_makespan_s", 0.0)), + mean_path_ratio=float(d.get("mean_path_ratio", 0.0)), + mean_turn_total_rad=float(d.get("mean_turn_total_rad", 0.0)), + mean_fuel=float(d.get("mean_fuel", 0.0)), + mean_penetration_pct=float(d.get("mean_penetration_pct", 0.0)), + veh_contacts_per_run=float(d.get("veh_contacts_per_run", 0.0)), + mean_min_sep_m=float(d.get("mean_min_sep_m", 0.0)), + mean_closest_approach_m=float(d.get("mean_closest_approach_m", 0.0)), + mean_stall_steps=float(d.get("mean_stall_steps", 0.0)), + # missing/null -> the 13-zero default (NOT []), matching the dataclass default so a + # partial producer that drops the all-zero share still loads a length-NUM_MATERIALS list + # (a downstream `share[m]` never IndexErrors). + material_time_share=[ + float(x) for x in (d.get("material_time_share") or [0.0] * NUM_MATERIALS) + ], + form_arrival_rate=float(d.get("form_arrival_rate", 0.0)), + form_mission_rate=float(d.get("form_mission_rate", 0.0)), + mean_slot_error_m=float(d.get("mean_slot_error_m", 0.0)), + mean_coverage=NavCoverage( + explored_frac=float(cov.get("explored_frac", 0.0)), + visible_frac=float(cov.get("visible_frac", 0.0)), + believed_free_frac=float(cov.get("believed_free_frac", 0.0)), + phantom_frac=float(cov.get("phantom_frac", 0.0)), + ), + mean_sense_flips=float(d.get("mean_sense_flips", 0.0)), + mean_alpha=float(d.get("mean_alpha", 0.0)), + mean_beta=float(d.get("mean_beta", 0.0)), + mean_gamma=float(d.get("mean_gamma", 0.0)), + mean_mu=float(d.get("mean_mu", 0.0)), + mean_mrisk=float(d.get("mean_mrisk", 0.0)), + mean_ext_force=float(d.get("mean_ext_force", 0.0)), + ) + + @classmethod + def from_json(cls, s: str) -> NavScorecard: + """Parse a scorecard JSON string (``to_json`` / C++ ``scorecard_json``) into a + ``NavScorecard``. See :meth:`from_dict`.""" + return cls.from_dict(json.loads(s)) + def aggregate_nav(episodes: list[EpisodeStats], checkpoint: str = "") -> NavScorecard: """Reduce a corpus of episodes into one base scorecard row. Same reduction as the diff --git a/tests/test_scorecard.py b/tests/test_scorecard.py index 9e719b9..d8c13eb 100644 --- a/tests/test_scorecard.py +++ b/tests/test_scorecard.py @@ -8,10 +8,12 @@ import math +from grl_snam.material_palette import NUM_MATERIALS from grl_snam.metrics import NavMetrics, NavStats from grl_snam.scorecard import ( EpisodeStats, NavCoverage, + NavScorecard, VehStats, aggregate_nav, compute_coverage, @@ -244,3 +246,63 @@ def test_scorecard_json_has_track1_keys(): "believed_free_frac", "phantom_frac", } + + +# --- Track 4 Phase 0: the scorecard reader (inverse of to_dict/to_json; loads C++ scorecard_json) --- + + +def test_scorecard_reader_round_trips(): + # A fully-populated scorecard (all Track-1 field families non-default) survives to_dict->from_dict + # and to_json->from_json unchanged. Since to_dict's keys are field-for-field the C++ + # cvc::nav::scorecard_json keys, this is also the guarantee that a recorded C++ scorecard row loads. + e0 = EpisodeStats( + success=True, + makespan_s=20, + penetration_pct=3, + min_sep_m=5, + coverage=NavCoverage(0.6, 0.3, 0.6, 0.2), + per_vehicle=[ + VehStats( + arrived=True, + convoy_id=0, + formation_parent=-1, + sense_flips=4, + drive_steps=2, + alpha_mean=1.0, + beta_mean=2.0, + gamma_mean=1.0, + mu_mean=0.5, + mrisk_mean=0.25, + ext_force_mean=1.5, + ), + VehStats( + arrived=True, + convoy_id=0, + formation_parent=0, + formation_arrived=True, + slot_error_mean_m=1.5, + closest_approach_m=3.0, + stall_steps=2, + ), + ], + ) + sc = aggregate_nav([e0], "ckpt-RT") + d = sc.to_dict() + assert NavScorecard.from_dict(d).to_dict() == d # dict round-trip is the exact inverse + assert NavScorecard.from_json(sc.to_json()).to_dict() == d # JSON round-trip too + + +def test_scorecard_reader_tolerates_partial_and_ignores_rf(): + # A partial producer (missing keys) loads with dataclass defaults; an embedded "rf" sub-object + # (the DBG scorecard_json shape) is ignored by the base reader. + sc = NavScorecard.from_dict( + {"checkpoint": "x", "success_rate": 0.5, "rf": {"outage_rate": 0.9}} + ) + assert sc.checkpoint == "x" + assert math.isclose(sc.success_rate, 0.5) + assert sc.n_episodes == 0 # missing scalar -> default + assert math.isclose(sc.form_arrival_rate, 0.0) # missing Track-1 field -> default + assert math.isclose(sc.mean_coverage.phantom_frac, 0.0) # missing nested -> default + # missing list field -> the length-NUM_MATERIALS zero default, NOT [] (so share[m] never IndexErrors) + assert sc.material_time_share == [0.0] * NUM_MATERIALS + assert not hasattr(sc, "rf") # rf sub-object dropped, not smuggled onto the base row