# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Method Runner for Reproducible Benchmarking
Provides a unified interface for running all 42 methods with pre-generated data.
"""
import time
import json
import pickle
import warnings
from copy import deepcopy
from pathlib import Path
from typing import Dict, Any, Optional
import networkx as nx
import numpy as np
from .dataset_registry import DatasetRegistry
from .seed_manager import SeedManager
[docs]
class MethodRunner:
"""
Unified runner for all QuVINE methods using pre-generated data.
Ensures all methods:
1. Load the same pre-generated graph
2. Use the same pre-generated split
3. Use the same canonical seed
4. Return results with full provenance
"""
[docs]
def __init__(
self,
registry: DatasetRegistry,
seed_manager: SeedManager
):
"""
Initialize method runner.
Parameters
----------
registry : DatasetRegistry
Dataset registry
seed_manager : SeedManager
Seed manager
"""
self.registry = registry
self.seed_manager = seed_manager
[docs]
def load_graph(self, dataset_name: str, repetition_id: int) -> nx.Graph:
"""
Load pre-generated graph from registry.
Parameters
----------
dataset_name : str
Dataset name
repetition_id : int
Repetition ID
Returns
-------
nx.Graph
Pre-generated graph
"""
entry = self.registry.get(dataset_name, repetition_id)
if entry is None:
raise ValueError(f"Dataset {dataset_name} rep {repetition_id} not found in registry")
if not entry.graph_path.exists():
raise FileNotFoundError(f"Graph file not found: {entry.graph_path}")
with open(entry.graph_path, 'rb') as f:
G = pickle.load(f)
return G
[docs]
def load_split(self, dataset_name: str, repetition_id: int, task: str) -> Dict[str, Any]:
"""
Load pre-generated split from registry.
Parameters
----------
dataset_name : str
Dataset name
repetition_id : int
Repetition ID
task : str
Task name
Returns
-------
dict
Pre-generated split data
"""
entry = self.registry.get(dataset_name, repetition_id)
if entry is None:
raise ValueError(f"Dataset {dataset_name} rep {repetition_id} not found in registry")
if task not in entry.split_paths:
raise ValueError(f"Task {task} not available for {dataset_name} rep {repetition_id}")
split_path = entry.split_paths[task]
if not split_path.exists():
raise FileNotFoundError(f"Split file not found: {split_path}")
# Try pickle first (faster)
pkl_path = split_path.with_suffix('.pkl')
if pkl_path.exists():
with open(pkl_path, 'rb') as f:
return pickle.load(f)
# Fall back to JSON
with open(split_path, 'r') as f:
return json.load(f)
[docs]
def load_train_graph(self, dataset_name: str, repetition_id: int, task: str) -> Optional[nx.Graph]:
"""
Load training graph for link prediction tasks.
For link prediction, we need a graph with test/val edges removed to prevent
data leakage. This is especially critical for methods like GAT/GraphGPS that
train on edges.
Parameters
----------
dataset_name : str
Dataset name
repetition_id : int
Repetition ID
task : str
Task name
Returns
-------
nx.Graph or None
Training graph if available (link prediction), None otherwise
"""
if task != "link_prediction":
return None
entry = self.registry.get(dataset_name, repetition_id)
if entry is None:
raise ValueError(f"Dataset {dataset_name} rep {repetition_id} not found in registry")
if task not in entry.split_paths:
raise ValueError(f"Task {task} not available for {dataset_name} rep {repetition_id}")
# The training graph (test/val edges removed) is produced by
# SplitGenerator.generate_link_prediction_split. It is stored under the
# "train_graph" key inside the split pickle; older runs may instead have
# written a sibling train_graph.pkl. Prefer the sibling file, then fall
# back to the in-split copy.
split_path = entry.split_paths[task]
train_graph_path = split_path.parent / "train_graph.pkl"
if train_graph_path.exists():
with open(train_graph_path, 'rb') as f:
return pickle.load(f)
with open(split_path, 'rb') as f:
split = pickle.load(f)
G_train = split.get("train_graph")
if G_train is None:
raise FileNotFoundError(
f"Training graph not found: neither {train_graph_path} exists nor "
f"does {split_path} contain a 'train_graph' key. This is required "
f"for link prediction to prevent data leakage."
)
return G_train
[docs]
def compute_graph_complexity(
self,
G: nx.Graph,
labels: Optional[np.ndarray] = None,
features: Optional[np.ndarray] = None
) -> Dict[str, float]:
"""
Compute graph complexity metrics.
Parameters
----------
G : nx.Graph
Graph to analyze
labels : np.ndarray, optional
Node labels for homophily computation
features : np.ndarray, optional
Node features for Dirichlet energy computation
Returns
-------
dict
Dictionary of complexity metrics (all float values)
"""
try:
from qbiocode.evaluation.graph_evaluation import compute_enhanced_complexity_metrics
# Compute complexity metrics
complexity_metrics = compute_enhanced_complexity_metrics(
G,
labels=labels,
features=features,
sanitize=True
)
return complexity_metrics
except Exception as e:
warnings.warn(f"Failed to compute complexity metrics: {e}")
# Return empty dict on failure to maintain type consistency
return {}
[docs]
def run_method(
self,
method_name: str,
dataset_name: str,
repetition_id: int,
task: str,
config: Optional[Dict[str, Any]] = None,
output_dir: Optional[Path] = None
) -> Dict[str, Any]:
"""
Run a method with pre-generated data.
Parameters
----------
method_name : str
Name of the method to run
dataset_name : str
Dataset name
repetition_id : int
Repetition ID
task : str
Task name
config : dict, optional
Method configuration/hyperparameters
output_dir : Path, optional
Directory to save results
Returns
-------
dict
Results with metrics and provenance
"""
# Get canonical seed
seed = self.seed_manager.get_seed(dataset_name, repetition_id)
task_seed = self.seed_manager.get_task_seed(dataset_name, repetition_id, task)
# Load pre-generated data
G = self.load_graph(dataset_name, repetition_id)
split = self.load_split(dataset_name, repetition_id, task)
# For link prediction, load training graph (with test/val edges removed)
# This prevents data leakage for methods that train on edges (e.g., GAT, GraphGPS)
# ALL methods must use the same graph structure for fair comparison
if task == "link_prediction":
G_train = self.load_train_graph(dataset_name, repetition_id, task)
if G_train is None:
raise RuntimeError(f"Training graph not available for link prediction task")
# Use training graph for embedding generation
G_for_method = G_train
else:
G_for_method = G
# Get registry entry for provenance
entry = self.registry.get(dataset_name, repetition_id)
if entry is None:
raise ValueError(f"Dataset {dataset_name} rep {repetition_id} not found in registry")
metadata = {}
with open(entry.metadata_path, 'r') as f:
metadata = json.load(f)
disease_nodes = None
if entry.disease_node_path is not None and entry.disease_node_path.exists():
with open(entry.disease_node_path, 'r') as f:
disease_nodes = json.load(f)
self._validate_fixed_inputs(
G=G,
split=split,
task=task,
metadata=metadata,
disease_nodes=disease_nodes,
seed=seed,
task_seed=task_seed,
dataset_name=dataset_name,
repetition_id=repetition_id,
)
# Extract node labels and features for complexity computation
node_labels = None
node_features = None
if G_for_method.number_of_nodes() > 0:
first_node = list(G_for_method.nodes())[0]
if 'label' in G_for_method.nodes[first_node]:
node_labels = np.array([G_for_method.nodes[n].get('label', -1) for n in G_for_method.nodes()])
if 'features' in G_for_method.nodes[first_node]:
node_features = np.array([G_for_method.nodes[n].get('features', []) for n in G_for_method.nodes()])
# Compute graph complexity metrics
complexity_metrics = self.compute_graph_complexity(
G_for_method,
labels=node_labels,
features=node_features
)
# Set random seeds for reproducibility
np.random.seed(task_seed)
try:
import torch
torch.manual_seed(task_seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(task_seed)
except ImportError:
pass
# Run method based on task
start_time = time.time()
graph_snapshot = pickle.dumps(G_for_method, protocol=pickle.HIGHEST_PROTOCOL)
split_for_method = deepcopy(split)
graph_for_method = pickle.loads(graph_snapshot)
if task == "node_classification":
metrics = self._run_node_classification(
method_name, graph_for_method, split_for_method, config, task_seed
)
elif task == "link_prediction":
metrics = self._run_link_prediction(
method_name, graph_for_method, split_for_method, config, task_seed
)
elif task == "node_ranking":
metrics = self._run_node_ranking(
method_name, graph_for_method, split_for_method, config, task_seed
)
else:
raise ValueError(f"Unknown task: {task}")
if pickle.dumps(G_for_method, protocol=pickle.HIGHEST_PROTOCOL) != graph_snapshot:
raise RuntimeError(f"Method {method_name} modified the input graph in-place, which is forbidden.")
runtime = time.time() - start_time
# Compile results with full provenance
results = {
"method_name": method_name,
"dataset_name": dataset_name,
"dataset_type": entry.dataset_type.value,
"ppi_source": entry.ppi_source,
"disease": entry.disease,
"requested_graph_size": entry.requested_size,
"actual_graph_size": entry.actual_size,
"repetition_id": repetition_id,
"task": task,
"seed": seed,
"task_seed": task_seed,
"graph_path": str(entry.graph_path),
"split_path": str(entry.split_paths[task]),
"metadata_path": str(entry.metadata_path),
"disease_node_path": str(entry.disease_node_path) if entry.disease_node_path else None,
"num_nodes": G_for_method.number_of_nodes(),
"num_edges": G_for_method.number_of_edges(),
"used_training_graph": task == "link_prediction",
"config": config or {},
"metrics": metrics,
"complexity_metrics": complexity_metrics,
"runtime_seconds": runtime,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
}
# Save results if output directory provided
if output_dir:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
result_file = output_dir / f"{method_name}_{task}_rep{repetition_id:02d}.json"
with open(result_file, 'w') as f:
json.dump(results, f, indent=2)
return results
def _validate_fixed_inputs(
self,
G: nx.Graph,
split: Dict[str, Any],
task: str,
metadata: Dict[str, Any],
disease_nodes: Optional[Dict[str, Any]],
seed: int,
task_seed: int,
dataset_name: str,
repetition_id: int,
) -> None:
"""Validate that runner inputs satisfy reproducibility and fairness constraints."""
if split.get("canonical_seed") is not None and split.get("canonical_seed") != seed:
raise ValueError(
f"Split canonical seed mismatch: split has {split.get('canonical_seed')} but runner derived {seed}"
)
# split["seed"] records the seed used to DRAW the split, which uses the
# task's split component (see SeedManager.TASK_TO_SPLIT_COMPONENT) — not the
# model-run task seed. Validate against the split component accordingly.
expected_split_seed = self.seed_manager.get_split_seed(dataset_name, repetition_id, task)
if split.get("seed") != expected_split_seed:
raise ValueError(
f"Split seed mismatch: split has {split.get('seed')} but runner derived {expected_split_seed}"
)
if split.get("dataset_name") != dataset_name:
raise ValueError(
f"Split dataset mismatch: split has {split.get('dataset_name')} but runner expected {dataset_name}"
)
if split.get("repetition_id") != repetition_id:
raise ValueError(
f"Split repetition mismatch: split has {split.get('repetition_id')} but runner expected {repetition_id}"
)
if split.get("task") != task:
raise ValueError(
f"Split task mismatch: split has {split.get('task')} but runner expected {task}"
)
if metadata.get("canonical_seed") is not None and metadata.get("canonical_seed") != seed:
raise ValueError(
f"Metadata canonical seed mismatch: metadata has {metadata.get('canonical_seed')} but runner derived {seed}"
)
if metadata.get("final_graph_connected") is False:
raise ValueError("Final saved graph is marked disconnected in metadata.")
if G.number_of_nodes() == 0:
raise ValueError("Input graph is empty.")
if task == "node_ranking":
expected_target_seed = self.seed_manager.get_component_seed(
dataset_name, repetition_id, "node_ranking_target_selection"
)
if split.get("target_selection_seed") != expected_target_seed:
raise ValueError(
f"Node-ranking target selection seed mismatch: split has {split.get('target_selection_seed')} "
f"but expected {expected_target_seed}"
)
elif task == "node_classification":
expected_split_seed = self.seed_manager.get_component_seed(
dataset_name, repetition_id, "node_classification_split"
)
if split.get("split_seed") != expected_split_seed:
raise ValueError(
f"Node-classification split seed mismatch: split has {split.get('split_seed')} "
f"but expected {expected_split_seed}"
)
elif task == "link_prediction":
expected_split_seed = self.seed_manager.get_component_seed(
dataset_name, repetition_id, "link_prediction_edge_split"
)
expected_negative_seed = self.seed_manager.get_component_seed(
dataset_name, repetition_id, "negative_edge_sampling"
)
if split.get("split_seed") != expected_split_seed:
raise ValueError(
f"Link-prediction split seed mismatch: split has {split.get('split_seed')} "
f"but expected {expected_split_seed}"
)
if split.get("negative_sampling_seed") != expected_negative_seed:
raise ValueError(
f"Negative-sampling seed mismatch: split has {split.get('negative_sampling_seed')} "
f"but expected {expected_negative_seed}"
)
if disease_nodes is not None:
missing_seed_nodes = [
node for node in disease_nodes.get("seed_node_ids", [])
if node not in G.nodes()
]
missing_target_nodes = [
node for node in disease_nodes.get("target_node_ids", [])
if node not in G.nodes()
]
if missing_seed_nodes or missing_target_nodes:
raise ValueError(
f"Preserved disease nodes missing from graph. "
f"Seeds: {missing_seed_nodes}, Targets: {missing_target_nodes}"
)
if task == "node_ranking":
if split.get("seed_nodes") != disease_nodes.get("seed_node_ids", []):
raise ValueError("Node-ranking split seed nodes differ from preserved disease seed nodes.")
if split.get("target_nodes") != disease_nodes.get("target_node_ids", []):
raise ValueError("Node-ranking split target nodes differ from preserved disease target nodes.")
def _run_node_classification(
self,
method_name: str,
G: nx.Graph,
split: Dict[str, Any],
config: Optional[Dict],
seed: int
) -> Dict[str, float]:
"""Run node classification task using method adapter."""
from .method_adapters import get_method_adapter
adapter = get_method_adapter(method_name)
return adapter(G, split, "node_classification", config, seed)
def _run_link_prediction(
self,
method_name: str,
G: nx.Graph,
split: Dict[str, Any],
config: Optional[Dict],
seed: int
) -> Dict[str, float]:
"""Run link prediction task using method adapter."""
from .method_adapters import get_method_adapter
adapter = get_method_adapter(method_name)
return adapter(G, split, "link_prediction", config, seed)
def _run_node_ranking(
self,
method_name: str,
G: nx.Graph,
split: Dict[str, Any],
config: Optional[Dict],
seed: int
) -> Dict[str, float]:
"""Run node ranking task using method adapter."""
from .method_adapters import get_method_adapter
adapter = get_method_adapter(method_name)
return adapter(G, split, "node_ranking", config, seed)
# Extract split data
seed_nodes = split["seed_nodes"]
target_nodes = split["target_nodes"]
# TODO: Call actual method implementation
# For now, return dummy metrics
metrics = {
"recall_at_10": 0.0,
"recall_at_50": 0.0,
"recall_at_100": 0.0,
"precision_at_10": 0.0,
"precision_at_50": 0.0,
"precision_at_100": 0.0,
"num_seeds": len(seed_nodes),
"num_targets": len(target_nodes)
}
return metrics
[docs]
def create_method_adapter(method_name: str):
"""
Create an adapter function that wraps an existing method to work with
the reproducible pipeline.
This is a helper for migrating existing methods.
Parameters
----------
method_name : str
Name of the method
Returns
-------
callable
Adapter function
"""
def adapter(
G: nx.Graph,
split: Dict[str, Any],
task: str,
seed: int,
config: Optional[Dict] = None
) -> Dict[str, float]:
"""
Adapter that calls the original method with pre-generated data.
Parameters
----------
G : nx.Graph
Pre-generated graph (DO NOT MODIFY)
split : dict
Pre-generated split
task : str
Task name
seed : int
Canonical seed
config : dict, optional
Method configuration
Returns
-------
dict
Metrics
"""
# Import the actual method
# This is where you would import and call the real implementation
# Example for a hypothetical method:
# from qbiocode.apps.quvine.methods import run_graphsage
# return run_graphsage(G, split, task, seed, config)
raise NotImplementedError(
f"Method {method_name} not yet adapted to reproducible pipeline. "
f"Please implement the adapter in method_runner.py"
)
return adapter