Skip to content

Commit ae5133a

Browse files
committed
test(03-02): add failing integration tests for trainer + graph proposer
- TestTrainerIntegration: 5 tests covering graph_data acceptance, forwarding, backward compat, full episode, and apply_to_graph_timeseries dispatch - TestScenarioDiversityExtended: distribution test for 50 graph-aware proposals - Tests fail (RED) because SelfPlayTrainer.__init__ lacks graph_data parameter
1 parent a8e0e10 commit ae5133a

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

tests/test_graph_proposer.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Backward compatibility with non-graph proposals
99
- Scenario diversity across graph-aware proposals
1010
- Per-node time-series application via apply_to_graph_timeseries
11+
- Trainer integration with graph_data forwarding (Plan 03-02)
1112
1213
Requirements: SELF-01 (topology-aware generation), SELF-02 (cascade propagation)
1314
"""
@@ -18,13 +19,17 @@
1819
import math
1920
import os
2021
import tempfile
22+
from unittest.mock import MagicMock, patch, call
2123

2224
import numpy as np
2325
import pytest
2426
import torch
2527
from torch_geometric.data import Data
2628

2729
from fyp.selfplay.proposer import ProposerAgent, ScenarioProposal
30+
from fyp.selfplay.solver import SolverAgent
31+
from fyp.selfplay.verifier import VerifierAgent
32+
from fyp.selfplay.trainer import SelfPlayTrainer
2833

2934

3035
# ============================================================================
@@ -569,3 +574,226 @@ def test_apply_without_graph_metadata_falls_back(self):
569574
for i in range(5):
570575
expected = proposal.apply_to_timeseries(baseline[i])
571576
np.testing.assert_allclose(result[i], expected, rtol=1e-6)
577+
578+
579+
# ============================================================================
580+
# TestTrainerIntegration (Plan 03-02)
581+
# ============================================================================
582+
583+
584+
class TestTrainerIntegration:
585+
"""Tests for SelfPlayTrainer integration with graph-aware proposer."""
586+
587+
@pytest.fixture
588+
def mock_solver(self):
589+
"""Create a MagicMock SolverAgent with expected interface."""
590+
solver = MagicMock(spec=SolverAgent)
591+
solver.predict.return_value = {"0.1": np.ones(48), "0.5": np.ones(48), "0.9": np.ones(48)}
592+
solver.train_step.return_value = 0.1
593+
solver.compute_forecast_loss.return_value = 0.1
594+
solver.use_samples = True
595+
return solver
596+
597+
@pytest.fixture
598+
def mock_verifier(self):
599+
"""Create a MagicMock VerifierAgent with expected interface."""
600+
verifier = MagicMock(spec=VerifierAgent)
601+
verifier.evaluate.return_value = (0.5, {"physics": {"violations": []}, "temporal": {"violations": []}})
602+
return verifier
603+
604+
@pytest.fixture
605+
def mock_proposer_with_graph(self, sample_graph_data):
606+
"""Create a MagicMock ProposerAgent returning graph-aware scenarios."""
607+
proposer = MagicMock(spec=ProposerAgent)
608+
proposer.scenario_buffer = []
609+
proposer.curriculum_level = 0.0
610+
611+
# Create a graph-aware scenario that will be returned
612+
scenario = ScenarioProposal(
613+
scenario_type="COLD_SNAP",
614+
magnitude=2.0,
615+
duration=48,
616+
start_time=None,
617+
affected_appliances=["heating"],
618+
baseline_context=np.ones(336),
619+
difficulty_score=0.5,
620+
physics_valid=True,
621+
metadata={
622+
"start_offset": 0,
623+
"graph_aware": True,
624+
"seed_nodes": [6, 7],
625+
"affected_nodes": {6: 1.0, 7: 1.0, 2: 0.7, 9: 0.49},
626+
"num_hops": 2,
627+
"decay_factor": 0.7,
628+
},
629+
)
630+
proposer.propose_scenario.return_value = scenario
631+
proposer.compute_learnability_reward.return_value = 0.5
632+
return proposer
633+
634+
def test_trainer_accepts_graph_data(
635+
self, mock_proposer_with_graph, mock_solver, mock_verifier, sample_graph_data
636+
):
637+
"""SelfPlayTrainer(proposer, solver, verifier, graph_data=graph_data)
638+
stores graph_data on the instance."""
639+
trainer = SelfPlayTrainer(
640+
mock_proposer_with_graph,
641+
mock_solver,
642+
mock_verifier,
643+
graph_data=sample_graph_data,
644+
)
645+
assert trainer.graph_data is sample_graph_data
646+
647+
def test_trainer_passes_graph_data_to_proposer(
648+
self, mock_proposer_with_graph, mock_solver, mock_verifier, sample_graph_data
649+
):
650+
"""During train_episode, proposer.propose_scenario is called with
651+
graph_data keyword arg."""
652+
trainer = SelfPlayTrainer(
653+
mock_proposer_with_graph,
654+
mock_solver,
655+
mock_verifier,
656+
graph_data=sample_graph_data,
657+
)
658+
659+
batch = [(np.random.rand(336), np.random.rand(48)) for _ in range(2)]
660+
trainer.train_episode(batch)
661+
662+
# Check that propose_scenario was called with graph_data
663+
for c in mock_proposer_with_graph.propose_scenario.call_args_list:
664+
assert "graph_data" in c.kwargs
665+
assert c.kwargs["graph_data"] is sample_graph_data
666+
667+
def test_trainer_without_graph_data_works(
668+
self, mock_proposer_with_graph, mock_solver, mock_verifier
669+
):
670+
"""SelfPlayTrainer without graph_data works as before (backward compat)."""
671+
# Create without graph_data (should default to None)
672+
trainer = SelfPlayTrainer(
673+
mock_proposer_with_graph, mock_solver, mock_verifier
674+
)
675+
assert trainer.graph_data is None
676+
677+
batch = [(np.random.rand(336), np.random.rand(48)) for _ in range(2)]
678+
metrics = trainer.train_episode(batch)
679+
680+
# Should complete normally and return valid metrics
681+
assert "episode" in metrics
682+
assert "scenarios" in metrics
683+
assert len(metrics["scenarios"]) == 2
684+
685+
def test_full_episode_with_graph_data(
686+
self, temp_constraints_file, sample_graph_data
687+
):
688+
"""train_episode returns valid metrics dict with graph-aware scenarios."""
689+
proposer = ProposerAgent(
690+
temp_constraints_file, difficulty_curriculum=True, random_seed=42
691+
)
692+
solver = SolverAgent(
693+
model_config={
694+
"patch_len": 8,
695+
"d_model": 16,
696+
"n_heads": 2,
697+
"n_layers": 1,
698+
"forecast_horizon": 48,
699+
"max_epochs": 1,
700+
},
701+
device="cpu",
702+
pretrain_epochs=0,
703+
use_samples=True,
704+
)
705+
verifier = VerifierAgent(temp_constraints_file)
706+
707+
trainer = SelfPlayTrainer(
708+
proposer, solver, verifier, graph_data=sample_graph_data
709+
)
710+
711+
batch = [(np.random.rand(336) * 2, np.random.rand(48) * 2) for _ in range(3)]
712+
metrics = trainer.train_episode(batch)
713+
714+
assert metrics["episode"] == 0
715+
assert len(metrics["scenarios"]) == 3
716+
assert "avg_solver_loss" in metrics
717+
assert "avg_verification_reward" in metrics
718+
assert "avg_mae" in metrics
719+
720+
def test_trainer_uses_graph_timeseries_when_graph_data(
721+
self, mock_solver, mock_verifier, sample_graph_data
722+
):
723+
"""When graph_data is present and ground_truth is 2-D, the trainer calls
724+
apply_to_graph_timeseries on the scenario."""
725+
# Create a mock proposer returning a scenario with a spied apply_to_graph_timeseries
726+
proposer = MagicMock(spec=ProposerAgent)
727+
proposer.scenario_buffer = []
728+
proposer.curriculum_level = 0.0
729+
730+
scenario = ScenarioProposal(
731+
scenario_type="COLD_SNAP",
732+
magnitude=2.0,
733+
duration=48,
734+
start_time=None,
735+
affected_appliances=["heating"],
736+
baseline_context=np.ones(336),
737+
difficulty_score=0.5,
738+
physics_valid=True,
739+
metadata={
740+
"start_offset": 0,
741+
"graph_aware": True,
742+
"seed_nodes": [0, 1],
743+
"affected_nodes": {0: 1.0, 1: 1.0, 2: 0.7},
744+
},
745+
)
746+
# Wrap scenario methods to track calls
747+
original_apply_graph = scenario.apply_to_graph_timeseries
748+
graph_call_count = [0]
749+
750+
def tracked_apply_graph(baseline):
751+
graph_call_count[0] += 1
752+
return original_apply_graph(baseline)
753+
754+
scenario.apply_to_graph_timeseries = tracked_apply_graph
755+
proposer.propose_scenario.return_value = scenario
756+
proposer.compute_learnability_reward.return_value = 0.5
757+
758+
trainer = SelfPlayTrainer(
759+
proposer, mock_solver, mock_verifier, graph_data=sample_graph_data
760+
)
761+
762+
# Use 2-D ground truth (num_nodes x timesteps)
763+
num_nodes = sample_graph_data.num_nodes
764+
batch = [
765+
(np.random.rand(336), np.random.rand(num_nodes, 48))
766+
for _ in range(2)
767+
]
768+
trainer.train_episode(batch)
769+
770+
# apply_to_graph_timeseries should have been called (once per batch item)
771+
assert graph_call_count[0] == 2, (
772+
f"Expected apply_to_graph_timeseries called 2 times, got {graph_call_count[0]}"
773+
)
774+
775+
776+
# ============================================================================
777+
# TestScenarioDiversity (additional tests from Plan 03-02)
778+
# ============================================================================
779+
780+
781+
class TestScenarioDiversityExtended:
782+
"""Extended scenario diversity tests for graph-aware proposer."""
783+
784+
def test_graph_proposer_scenario_type_distribution(
785+
self, proposer, sample_graph_data
786+
):
787+
"""Over 50 proposals with graph_data, at least 3 unique scenario types appear."""
788+
context = np.random.rand(336)
789+
types_seen = set()
790+
for _ in range(50):
791+
scenario = proposer.propose_scenario(
792+
historical_context=context,
793+
graph_data=sample_graph_data,
794+
forecast_horizon=48,
795+
)
796+
types_seen.add(scenario.scenario_type)
797+
assert len(types_seen) >= 3, (
798+
f"Only saw {types_seen} in 50 proposals, expected >= 3 types"
799+
)

0 commit comments

Comments
 (0)