Co-Study4Grid / expert_backend /tests /test_model_composition.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
13.7 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# SPDX-License-Identifier: MPL-2.0
"""Tests for the explicit RecommenderService ⇄ recommender-registry composition.
Successor of the orphaned root ``tests/test_service_integration.py``
(2026-07 D1 revision). The integration is no longer an import-time
monkey-patch: :class:`RecommenderService` inherits
:class:`ModelSelectionMixin` directly, ``update_config`` / ``reset``
call ``_apply_model_settings`` / ``_reset_model_settings`` themselves,
and the single, model-aware ``run_analysis_step2`` lives on
:class:`AnalysisMixin`. These tests pin that wiring — and would fail
loudly if anyone reintroduced a shadowing wrapper.
Needs the real ``expert_op4grid_recommender`` (``run_analysis_step2``
builds recommenders from the registry, whose package ``__init__``
imports the concrete model classes), so it is skipped under the
conftest mock layer.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
pytest.importorskip("expert_op4grid_recommender.models.base")
from expert_backend.services.model_selection_mixin import ModelSelectionMixin # noqa: E402
from expert_backend.services.recommender_service import ( # noqa: E402
RecommenderService,
recommender_service,
)
# ---------------------------------------------------------------------
# Composition wiring
# ---------------------------------------------------------------------
def test_service_inherits_model_selection_mixin():
assert ModelSelectionMixin in RecommenderService.__mro__
def test_service_class_has_model_selection_helpers():
for attr in (
"get_active_model_name",
"get_compute_overflow_graph",
"_reset_model_settings",
"_apply_model_settings",
):
assert hasattr(RecommenderService, attr), f"missing {attr!r}"
def test_no_import_time_wrappers_remain():
"""The de-ghosting contract: production methods are the ones defined
in their home modules — no ``_with_model`` wrapper shadows them."""
assert RecommenderService.update_config.__name__ == "update_config"
assert RecommenderService.reset.__name__ == "reset"
assert RecommenderService.run_analysis_step2.__name__ == "run_analysis_step2"
assert (
RecommenderService.run_analysis_step2.__module__
== "expert_backend.services.analysis_mixin"
)
def test_singleton_has_default_model_state():
# __init__ initialises the model-selection state, so the module-level
# singleton exposes the defaults before /api/config is ever called.
assert recommender_service.get_active_model_name() == "expert"
assert recommender_service.get_compute_overflow_graph() is True
def test_fresh_instance_has_default_model_state():
svc = RecommenderService()
assert svc.get_active_model_name() == "expert"
assert svc.get_compute_overflow_graph() is True
def test_reset_restores_model_defaults():
svc = RecommenderService()
svc._recommender_model_name = "random"
svc._compute_overflow_graph = False
svc.reset()
assert svc.get_active_model_name() == "expert"
assert svc.get_compute_overflow_graph() is True
def test_update_config_captures_model_selection(tmp_path):
"""``update_config`` applies the two model-selection fields itself
(formerly done by an import-time wrapper)."""
svc = RecommenderService()
settings = SimpleNamespace(
network_path=str(tmp_path / "net.xiidm"),
action_file_path=str(tmp_path / "actions.json"),
min_line_reconnections=2.0,
min_close_coupling=3.0,
min_open_coupling=2.0,
min_line_disconnections=3.0,
n_prioritized_actions=10,
model="random_overflow",
compute_overflow_graph=False,
)
with patch.object(RecommenderService, "prefetch_base_nad_async"), \
patch(
"expert_backend.services.recommender_service.load_actions",
return_value={"disco_X": {"description": "d"}},
), \
patch(
"expert_backend.services.recommender_service.enrich_actions_lazy",
side_effect=lambda raw, net: raw,
):
svc.update_config(settings)
assert svc.get_active_model_name() == "random_overflow"
assert svc.get_compute_overflow_graph() is False
# ---------------------------------------------------------------------
# Model-aware run_analysis_step2 behaviour
# ---------------------------------------------------------------------
def test_run_analysis_step2_requires_context():
"""Without step-1 having populated the context, step-2 must error out."""
svc = RecommenderService()
svc._analysis_context = None
gen = svc.run_analysis_step2(selected_overloads=[])
with pytest.raises(ValueError, match="Analysis context not found"):
next(gen)
def test_run_analysis_step2_emits_error_for_unknown_model():
"""Unknown model -> single error event then closes the stream."""
svc = RecommenderService()
# Fake a non-empty context so we reach the model build step.
svc._analysis_context = {
"lines_overloaded_names": [],
"lines_overloaded_ids": [],
"lines_overloaded_ids_kept": [],
"lines_we_care_about": None,
}
svc._recommender_model_name = "__not_a_model__"
svc._compute_overflow_graph = False
events = list(svc.run_analysis_step2(
selected_overloads=[],
all_overloads=[],
monitor_deselected=False,
additional_lines_to_cut=[],
))
assert len(events) == 1
assert events[0]["type"] == "error"
assert "__not_a_model__" in events[0]["message"]
# ---------------------------------------------------------------------
# Step-2 overflow-graph cache on the model-aware path.
#
# The overflow graph is model-INDEPENDENT — only action discovery
# consumes the recommender — so a re-run with the same contingency +
# Step-2 inputs but a different model must REUSE the cached graph and
# skip `run_analysis_step2_graph`.
# ---------------------------------------------------------------------
def _seed_step2_state(svc, tmp_path):
"""Put `svc` into the post-step1 state and stub the per-instance
helpers so `run_analysis_step2` runs end to end without the heavy
pipeline. Returns the fake produced-HTML path."""
svc._reset_model_settings()
svc._last_disconnected_elements = ["LINE_C"]
svc._analysis_context = {
"lines_overloaded_names": ["L1"],
"lines_overloaded_ids": [0],
"lines_overloaded_ids_kept": [0],
"lines_we_care_about": None,
}
svc._last_step2_context = None
svc._last_step2_signature = None
svc._overflow_layout_cache = {}
pdf = tmp_path / "overflow.html"
pdf.write_text("<html></html>")
svc._narrow_context_to_selected_overloads = MagicMock(side_effect=lambda ctx, *a, **k: ctx)
svc._get_latest_pdf_path = MagicMock(return_value=str(pdf))
svc._enrich_actions = MagicMock(return_value={})
svc._augment_combined_actions_with_target_max_rho = MagicMock()
svc._compute_mw_start_for_scores = MagicMock(return_value={})
return str(pdf)
def _graph_required_recommender(name="expert"):
rec = MagicMock()
rec.requires_overflow_graph = True
rec.name = name
return rec
_DISCOVERY_RESULT = {
"prioritized_actions": {},
"action_scores": {},
"lines_overloaded_names": ["L1"],
}
def test_unchanged_signature_reuses_overflow_graph(tmp_path):
"""Re-running with an identical signature (only the model swapped)
skips `run_analysis_step2_graph` and reuses the cached graph —
discovery still re-runs because it's the model-dependent step."""
svc = RecommenderService()
expected_pdf = _seed_step2_state(svc, tmp_path)
with patch(
"expert_backend.recommenders.registry.build_recommender",
return_value=_graph_required_recommender(),
), patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_graph",
side_effect=lambda ctx: ctx,
) as mock_graph, patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_discovery",
return_value=dict(_DISCOVERY_RESULT),
) as mock_discovery:
kwargs = dict(
selected_overloads=["L1"], all_overloads=["L1"],
monitor_deselected=False, additional_lines_to_cut=["EXTRA"],
)
# First run — builds the graph and seeds the cache.
events1 = list(svc.run_analysis_step2(**kwargs))
assert mock_graph.call_count == 1
assert svc._last_step2_signature is not None
pdf_event1 = next(e for e in events1 if e.get("type") == "pdf")
assert pdf_event1["pdf_path"] == expected_pdf
# Second run, identical signature — graph rebuild is skipped,
# discovery re-runs (a model swap only affects discovery).
events2 = list(svc.run_analysis_step2(**kwargs))
assert mock_graph.call_count == 1 # NOT rebuilt
assert mock_discovery.call_count == 2 # discovery re-ran
pdf_event2 = next(e for e in events2 if e.get("type") == "pdf")
assert pdf_event2["pdf_path"] == expected_pdf
assert pdf_event2.get("cached") is True
def test_changed_additional_lines_rebuilds_overflow_graph(tmp_path):
"""Changing the `additional_lines_to_cut` hypothesis changes the
signature, so the overflow graph MUST be rebuilt."""
svc = RecommenderService()
_seed_step2_state(svc, tmp_path)
with patch(
"expert_backend.recommenders.registry.build_recommender",
return_value=_graph_required_recommender(),
), patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_graph",
side_effect=lambda ctx: ctx,
) as mock_graph, patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_discovery",
return_value=dict(_DISCOVERY_RESULT),
):
list(svc.run_analysis_step2(
selected_overloads=["L1"], all_overloads=["L1"],
monitor_deselected=False, additional_lines_to_cut=["EXTRA"],
))
assert mock_graph.call_count == 1
list(svc.run_analysis_step2(
selected_overloads=["L1"], all_overloads=["L1"],
monitor_deselected=False, additional_lines_to_cut=["OTHER"],
))
assert mock_graph.call_count == 2 # rebuilt for the new signature
def test_graph_skipping_model_does_not_reuse_or_seed_cache(tmp_path):
"""A model that doesn't need the overflow graph never builds OR
reuses it — and clears the signature so a later graph-requiring run
can't false-hit on it."""
svc = RecommenderService()
_seed_step2_state(svc, tmp_path)
# Pre-seed a stale cache to prove the no-graph path clears it.
svc._last_step2_signature = ("stale",)
svc._last_step2_context = {"stale": True}
no_graph_rec = MagicMock()
no_graph_rec.requires_overflow_graph = False
no_graph_rec.name = "random"
with patch(
"expert_backend.recommenders.registry.build_recommender",
return_value=no_graph_rec,
), patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_graph",
) as mock_graph, patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_discovery",
return_value=dict(_DISCOVERY_RESULT),
):
# The operator did not opt into the (expensive) graph build, and
# the model doesn't require it → the graph step is skipped.
svc._compute_overflow_graph = False
events = list(svc.run_analysis_step2(
selected_overloads=["L1"], all_overloads=["L1"],
monitor_deselected=False, additional_lines_to_cut=[],
))
mock_graph.assert_not_called()
pdf_event = next(e for e in events if e.get("type") == "pdf")
assert pdf_event["pdf_path"] is None
assert svc._last_step2_signature is None
assert svc._last_step2_context is None
def test_result_event_restores_antenna_meta_from_discovery():
"""Regression guard for the antenna_meta mirror-drift bug: the
model-aware generator must forward ``antenna_meta`` from the
discovery results to the result event (the frontend's AntennaNotice
reads it). The pre-D1 production wrapper silently dropped it."""
svc = RecommenderService()
svc._reset_model_settings()
svc._compute_overflow_graph = False
svc._last_disconnected_elements = ["LINE_C"]
svc._analysis_context = {
"lines_overloaded_names": ["L1"],
"lines_overloaded_ids": [0],
"lines_overloaded_ids_kept": [0],
"lines_we_care_about": None,
}
svc._narrow_context_to_selected_overloads = MagicMock(side_effect=lambda ctx, *a, **k: ctx)
svc._enrich_actions = MagicMock(return_value={})
svc._augment_combined_actions_with_target_max_rho = MagicMock()
svc._compute_mw_start_for_scores = MagicMock(return_value={})
no_graph_rec = MagicMock()
no_graph_rec.requires_overflow_graph = False
no_graph_rec.name = "random"
antenna = {"pocket_subs": ["SUB_A"], "direction": "import"}
with patch(
"expert_backend.recommenders.registry.build_recommender",
return_value=no_graph_rec,
), patch(
"expert_backend.services.analysis_mixin.run_analysis_step2_discovery",
return_value={**_DISCOVERY_RESULT, "antenna_meta": antenna},
):
events = list(svc.run_analysis_step2(selected_overloads=["L1"]))
result_event = next(e for e in events if e.get("type") == "result")
assert result_event["antenna_meta"] == antenna