|
8 | 8 | - Backward compatibility with non-graph proposals |
9 | 9 | - Scenario diversity across graph-aware proposals |
10 | 10 | - Per-node time-series application via apply_to_graph_timeseries |
| 11 | +- Trainer integration with graph_data forwarding (Plan 03-02) |
11 | 12 |
|
12 | 13 | Requirements: SELF-01 (topology-aware generation), SELF-02 (cascade propagation) |
13 | 14 | """ |
|
18 | 19 | import math |
19 | 20 | import os |
20 | 21 | import tempfile |
| 22 | +from unittest.mock import MagicMock, patch, call |
21 | 23 |
|
22 | 24 | import numpy as np |
23 | 25 | import pytest |
24 | 26 | import torch |
25 | 27 | from torch_geometric.data import Data |
26 | 28 |
|
27 | 29 | 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 |
28 | 33 |
|
29 | 34 |
|
30 | 35 | # ============================================================================ |
@@ -569,3 +574,226 @@ def test_apply_without_graph_metadata_falls_back(self): |
569 | 574 | for i in range(5): |
570 | 575 | expected = proposal.apply_to_timeseries(baseline[i]) |
571 | 576 | 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