Co-Study4Grid / scripts /check_session_fidelity.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
15.8 kB
#!/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())