#!/usr/bin/env python3 # Copyright (c) 2025-2026, RTE (https://www.rte-france.com) # SPDX-License-Identifier: MPL-2.0 """Layer-2 session-reload fidelity check. ``docs/features/interaction-logging.md § Session reload fidelity`` documents a contract that EVERY field persisted into ``session.json`` must also be restored into the live app state on reload. History has shown that this contract regresses silently — PR #88 persisted ``n_overloads_rho`` but the React restore path dropped it on the floor; PRs #73 / #78 / #83 shipped ``load_shedding_details`` / ``pst_details`` / ``lines_overloaded_after`` that the restore path then quietly discarded. This script takes a **curated list of critical fields** and checks, for each one: 1. It appears in the React **save** path (``frontend/src/utils/sessionUtils.ts``). 2. It appears in the React **restore** path (``frontend/src/hooks/useSession.ts``). 3. It appears in the **standalone** HTML (which contains both save and restore logic in one file). A field that is saved but not restored is a silent regression — the app will quietly lose data on reload. The script FAILs on any such asymmetry so CI can gate on it. The spec table is deliberately curated rather than derived from the ``SessionResult`` TypeScript interface. A field in the interface is not automatically "critical" — some fields (e.g. ``saved_at``) are metadata that has no meaningful restore-side equivalent. Growing this list is the right feedback loop: when a new field is added to ``SessionResult``, the author decides whether it needs reload fidelity and adds it here. Run:: python scripts/check_session_fidelity.py # human output python scripts/check_session_fidelity.py --json # CI-friendly Exits non-zero on any FAIL-level finding. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path import os REPO_ROOT = Path(__file__).resolve().parent.parent FE_SAVE_PATH = REPO_ROOT / "frontend" / "src" / "utils" / "sessionUtils.ts" FE_RESTORE_PATH = REPO_ROOT / "frontend" / "src" / "hooks" / "useSession.ts" _DEFAULT_STANDALONE = REPO_ROOT / "frontend" / "dist-standalone" / "standalone.html" _LEGACY_STANDALONE = REPO_ROOT / "standalone_interface_legacy.html" STANDALONE = Path( os.environ.get( "COSTUDY4GRID_STANDALONE_PATH", str(_DEFAULT_STANDALONE if _DEFAULT_STANDALONE.exists() else _LEGACY_STANDALONE), ) ) # Curated list of fields that MUST survive a save/reload round-trip. # Each entry declares: # - ``field``: the JSON-on-disk key (snake_case, matching the session.json shape). # - ``restore_token``: a token the restore path should reference to flow # the field back into live state. Usually same as ``field``; sometimes # a setter name when the JSON key differs from the state variable # (e.g. ``committed_network_path`` ↔ ``committedNetworkPathRef``). # - ``optional_on_save``: if True, missing from the save path is a WARN # (the field was newly added; older sessions won't have it). # - ``spec``: one-line reference to where in the replay contract this # field is required. SESSION_FIELDS = [ # --- Configuration (must round-trip every recommender threshold) --- {"field": "network_path", "restore_token": "network_path", "spec": "cfg.network_path → setNetworkPath + committedNetworkPathRef"}, {"field": "action_file_path", "restore_token": "action_file_path", "spec": "cfg.action_file_path → setActionPath"}, {"field": "layout_path", "restore_token": "layout_path", "spec": "cfg.layout_path → setLayoutPath"}, {"field": "min_line_reconnections", "restore_token": "min_line_reconnections"}, {"field": "min_close_coupling", "restore_token": "min_close_coupling"}, {"field": "min_open_coupling", "restore_token": "min_open_coupling"}, {"field": "min_line_disconnections", "restore_token": "min_line_disconnections"}, {"field": "min_pst", "restore_token": "min_pst"}, {"field": "min_load_shedding", "restore_token": "min_load_shedding", "spec": "power-reduction format (PR #73) — fallback 0.0 on older sessions"}, {"field": "min_renewable_curtailment_actions", "restore_token": "min_renewable_curtailment_actions", "spec": "power-reduction format (PR #73) — fallback 0.0 on older sessions"}, {"field": "min_redispatch", "restore_token": "min_redispatch", "spec": "recommender redispatch floor — fallback 0.0 on older sessions"}, {"field": "allowed_action_types", "restore_token": "allowed_action_types", "spec": "recommender action-type restriction — fallback [] (all families) on older sessions"}, {"field": "n_prioritized_actions", "restore_token": "n_prioritized_actions"}, {"field": "lines_monitoring_path", "restore_token": "lines_monitoring_path"}, {"field": "monitoring_factor", "restore_token": "monitoring_factor"}, {"field": "pre_existing_overload_threshold", "restore_token": "pre_existing_overload_threshold"}, {"field": "ignore_reconnections", "restore_token": "ignore_reconnections"}, {"field": "pypowsybl_fast_mode", "restore_token": "pypowsybl_fast_mode"}, # --- Contingency --- {"field": "disconnected_element", "restore_token": "disconnected_element", "spec": "contingency.disconnected_element → setSelectedBranch + committedBranchRef"}, {"field": "selected_overloads", "restore_token": "selected_overloads"}, {"field": "monitor_deselected", "restore_token": "monitor_deselected"}, # --- Overloads (incl. sticky-header rho ratios added by PR #88) --- # Save-only-OK: the live UI rebuilds these from a fresh N-1 diagram # fetch triggered by `setSelectedBranch` after restore, so the # on-disk arrays are only for inspection / offline replay agents. # See docs/features/interaction-logging.md § Session reload fidelity. {"field": "n_overloads", "restore_token": "n_overloads", "save_only_ok": True, "spec": "re-derived from fresh N-1 diagram on reload (save for inspection only)"}, {"field": "n1_overloads", "restore_token": "n1_overloads", "save_only_ok": True, "spec": "re-derived from fresh N-1 diagram on reload (save for inspection only)"}, {"field": "resolved_overloads", "restore_token": "resolved_overloads"}, {"field": "n_overloads_rho", "restore_token": "n_overloads_rho", "optional_on_save": True, "save_only_ok": True, "spec": "sticky sidebar rho (PR #88) — re-derived on reload, save for inspection"}, {"field": "n1_overloads_rho", "restore_token": "n1_overloads_rho", "optional_on_save": True, "save_only_ok": True, "spec": "sticky sidebar rho (PR #88) — re-derived on reload, save for inspection"}, # --- Analysis-result enrichment (commonly-dropped fields) --- # These were the class of field that was silently dropped by # handleRestoreSession before PRs #73/#78/#83 landed — the editor # cards rendered empty after reload until the user re-ran # analysis. Each one MUST be referenced in restore code. {"field": "load_shedding_details", "restore_token": "load_shedding_details", "spec": "SavedActionEntry → live ActionDetail.load_shedding_details"}, {"field": "curtailment_details", "restore_token": "curtailment_details", "spec": "SavedActionEntry → live ActionDetail.curtailment_details"}, {"field": "pst_details", "restore_token": "pst_details", "spec": "SavedActionEntry → live ActionDetail.pst_details (PST editor)"}, {"field": "redispatch_details", "restore_token": "redispatch_details", "spec": "SavedActionEntry → live ActionDetail.redispatch_details (redispatch editor, max-raise/lower headroom)"}, {"field": "lines_overloaded_after", "restore_token": "lines_overloaded_after", "spec": "post-action NAD/SLD overload halos (PR #83)"}, {"field": "action_topology", "restore_token": "action_topology"}, # --- Interaction log --- {"field": "interaction_log", "restore_token": "interaction_log", "optional_on_save": True, "spec": "replay-ready gesture log (written as interaction_log.json alongside)"}, ] def load(path: Path) -> str: return path.read_text(encoding="utf-8", errors="replace") def check_field_presence( field: dict, sources: dict[str, str], ) -> dict: """For one curated field, check presence in each codebase surface.""" save_pat = re.compile(r"\b" + re.escape(field["field"]) + r"\b") restore_pat = re.compile(r"\b" + re.escape(field["restore_token"]) + r"\b") result = {"field": field["field"], "spec": field.get("spec", ""), "optional_on_save": field.get("optional_on_save", False)} for name, src in sources.items(): pat = save_pat if name.endswith("_save") else restore_pat matches = [ (src.count("\n", 0, m.start()) + 1) for m in pat.finditer(src) ] result[name] = matches return result def run_checks() -> dict: fe_save = load(FE_SAVE_PATH) fe_restore = load(FE_RESTORE_PATH) sa = load(STANDALONE) sources = { "fe_save": fe_save, "fe_restore": fe_restore, # The standalone combines save and restore in one file; the # same token search works for both directions, but we report # them separately so the absence of either direction is # visible in the output. "sa_save": sa, "sa_restore": sa, } findings = [] for field in SESSION_FIELDS: res = check_field_presence(field, sources) # Evaluate round-trip for each codebase. A field is a # regression candidate if it is saved but never restored. fe_saved = bool(res["fe_save"]) fe_restored = bool(res["fe_restore"]) sa_saved = bool(res["sa_save"]) sa_restored = bool(res["sa_restore"]) optional = bool(field.get("optional_on_save")) save_only_ok = bool(field.get("save_only_ok")) fe_status = _classify(fe_saved, fe_restored, optional, save_only_ok) sa_status = _classify(sa_saved, sa_restored, optional, save_only_ok) findings.append({ "field": res["field"], "spec": res["spec"], "fe_save_lines": res["fe_save"], "fe_restore_lines": res["fe_restore"], "sa_save_lines": res["sa_save"], "sa_restore_lines": res["sa_restore"], "fe_status": fe_status, "sa_status": sa_status, }) return {"findings": findings} def _classify(saved: bool, restored: bool, optional: bool, save_only_ok: bool) -> str: if saved and restored: return "ok" if saved and not restored: # Save-only fields are intentional (re-derived from a fresh # backend fetch on reload) — report as "save_only" rather # than "regression" so the script doesn't bark on them. return "save_only" if save_only_ok else "regression" if not saved and not restored: return "missing" # warn — field declared but neither side implements # Not saved but restored — unusual; could be a default fallback. # Flag as warn so authors see it. if optional: return "optional_missing" return "restore_only" def render_human(report: dict) -> str: out: list[str] = [] out.append("SESSION-RELOAD FIDELITY REPORT") out.append("=" * 72) fe_regressions = [f for f in report["findings"] if f["fe_status"] == "regression"] sa_regressions = [f for f in report["findings"] if f["sa_status"] == "regression"] fe_missing = [f for f in report["findings"] if f["fe_status"] == "missing"] sa_missing = [f for f in report["findings"] if f["sa_status"] == "missing"] if fe_regressions: out.append(f"[FAIL] {len(fe_regressions)} fields the React frontend PERSISTS " f"but never RESTORES (silent reload regression):") for f in fe_regressions: lines = ", ".join(str(n) for n in f["fe_save_lines"][:3]) out.append(f" - {f['field']:35s} (saved at sessionUtils.ts:{lines})") if f["spec"]: out.append(f" spec: {f['spec']}") out.append("") if sa_regressions: out.append(f"[FAIL] {len(sa_regressions)} fields the standalone PERSISTS " f"but never RESTORES:") for f in sa_regressions: lines = ", ".join(str(n) for n in f["sa_save_lines"][:3]) out.append(f" - {f['field']:35s} (saved at standalone_interface.html:{lines})") if f["spec"]: out.append(f" spec: {f['spec']}") out.append("") if fe_missing: out.append(f"[WARN] {len(fe_missing)} required fields are absent from BOTH " f"React save and restore paths:") for f in fe_missing: out.append(f" - {f['field']}") if f["spec"]: out.append(f" spec: {f['spec']}") out.append("") if sa_missing: out.append(f"[WARN] {len(sa_missing)} required fields are absent from the " f"standalone (neither saved nor restored):") for f in sa_missing: out.append(f" - {f['field']}") if f["spec"]: out.append(f" spec: {f['spec']}") out.append("") restore_only = [f for f in report["findings"] if f["fe_status"] == "restore_only"] if restore_only: out.append(f"[FAIL] {len(restore_only)} fields the React frontend RESTORES " f"but never SAVES — the field will always be `undefined` on " f"reload since it was never written to session.json:") for f in restore_only: lines = ", ".join(str(n) for n in f["fe_restore_lines"][:3]) out.append(f" - {f['field']:35s} (restore at useSession.ts:{lines})") if f["spec"]: out.append(f" spec: {f['spec']}") out.append("") sa_restore_only = [f for f in report["findings"] if f["sa_status"] == "restore_only"] if sa_restore_only: out.append(f"[FAIL] {len(sa_restore_only)} fields the standalone RESTORES " f"but never SAVES:") for f in sa_restore_only: lines = ", ".join(str(n) for n in f["sa_restore_lines"][:3]) out.append(f" - {f['field']:35s} (restore at standalone_interface.html:{lines})") out.append("") if not (fe_regressions or sa_regressions): out.append("[OK] All curated session fields round-trip through both codebases.") # Summary counts total = len(report["findings"]) fe_ok = sum(1 for f in report["findings"] if f["fe_status"] == "ok") sa_ok = sum(1 for f in report["findings"] if f["sa_status"] == "ok") out.append("") out.append(f"Summary: {fe_ok}/{total} fields round-trip on React, " f"{sa_ok}/{total} on standalone.") return "\n".join(out) def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--json", action="store_true", help="emit JSON instead of text") args = parser.parse_args() report = run_checks() if args.json: print(json.dumps(report, indent=2, sort_keys=True)) else: print(render_human(report)) hard_fail = any( f["fe_status"] in ("regression", "restore_only") or f["sa_status"] in ("regression", "restore_only") for f in report["findings"] ) return 1 if hard_fail else 0 if __name__ == "__main__": sys.exit(main())