| """ |
| Experiment Manager for AegisLM Framework |
| |
| Production-grade experiment lifecycle management with full tracking. |
| """ |
|
|
| import uuid |
| from datetime import datetime |
| from typing import Dict, Any, Optional, List |
| from pathlib import Path |
| import sys |
|
|
| |
| current_dir = Path(__file__).parent |
| backend_dir = current_dir.parent |
| if str(backend_dir) not in sys.path: |
| sys.path.insert(0, str(backend_dir)) |
|
|
| from schemas.experiment_schema import ( |
| Experiment, ExperimentCreate, ExperimentUpdate, ExperimentStatus, |
| ExperimentPriority, ConfigSnapshot, ResultSummary, ExperimentFilter, |
| ExperimentList, ExperimentComparison, ExperimentStats |
| ) |
| from experiments.experiment_store import ExperimentStore |
|
|
|
|
| class ExperimentManager: |
| """ |
| Production-grade experiment manager for lifecycle management. |
| |
| Handles experiment creation, status updates, result storage, and retrieval. |
| """ |
| |
| def __init__(self, store: Optional[ExperimentStore] = None): |
| """ |
| Initialize experiment manager. |
| |
| Args: |
| store: Optional experiment store instance |
| """ |
| self.store = store or ExperimentStore() |
| |
| def create_experiment(self, config: ExperimentCreate) -> Experiment: |
| """ |
| Create a new experiment with unique run_id. |
| |
| Args: |
| config: Experiment creation configuration |
| |
| Returns: |
| Created experiment with unique run_id |
| |
| Raises: |
| ValueError: If configuration is invalid |
| """ |
| |
| run_id = uuid.uuid4() |
| |
| |
| config_snapshot = config.config_snapshot |
| |
| |
| experiment = Experiment( |
| run_id=run_id, |
| experiment_name=config.experiment_name, |
| description=config.description, |
| config_snapshot=config_snapshot, |
| |
| |
| model_name=config_snapshot.model_name, |
| dataset_name=config_snapshot.dataset_name, |
| dataset_version=config_snapshot.dataset_version, |
| attack_types=config_snapshot.attack_types, |
| prompt_count=config_snapshot.prompt_count, |
| |
| |
| status=ExperimentStatus.PENDING, |
| priority=config.priority, |
| tags=config.tags, |
| created_by=config.created_by, |
| parent_experiment_id=config.parent_experiment_id |
| ) |
| |
| |
| stored_experiment = self.store.create_experiment(experiment) |
| |
| return stored_experiment |
| |
| def update_status(self, run_id: str, status: ExperimentStatus) -> Experiment: |
| """ |
| Update experiment status. |
| |
| Args: |
| run_id: Unique experiment identifier |
| status: New status |
| |
| Returns: |
| Updated experiment |
| |
| Raises: |
| ValueError: If experiment not found or status transition invalid |
| """ |
| |
| experiment = self.store.get_experiment(run_id) |
| if not experiment: |
| raise ValueError(f"Experiment with run_id {run_id} not found") |
| |
| |
| self._validate_status_transition(experiment.status, status) |
| |
| |
| update_data = ExperimentUpdate(status=status) |
| |
| |
| if status == ExperimentStatus.RUNNING and experiment.status != ExperimentStatus.RUNNING: |
| experiment.mark_as_running() |
| update_data.status = ExperimentStatus.RUNNING |
| elif status == ExperimentStatus.COMPLETED: |
| if not experiment.result_summary: |
| raise ValueError("Cannot mark experiment as completed without results") |
| experiment.mark_as_completed(experiment.result_summary, experiment.full_result) |
| elif status == ExperimentStatus.FAILED: |
| if not experiment.error_message: |
| raise ValueError("Cannot mark experiment as failed without error message") |
| experiment.mark_as_failed(experiment.error_message) |
| elif status == ExperimentStatus.CANCELLED: |
| experiment.mark_as_cancelled() |
| |
| |
| updated_experiment = self.store.update_experiment(run_id, update_data) |
| |
| return updated_experiment |
| |
| def save_results(self, run_id: str, result_summary: ResultSummary, |
| full_result: Optional[Dict[str, Any]] = None) -> Experiment: |
| """ |
| Save experiment results and mark as completed. |
| |
| Args: |
| run_id: Unique experiment identifier |
| result_summary: Summary of experiment results |
| full_result: Complete result data (optional) |
| |
| Returns: |
| Updated experiment with results |
| |
| Raises: |
| ValueError: If experiment not found or not in running state |
| """ |
| |
| experiment = self.store.get_experiment(run_id) |
| if not experiment: |
| raise ValueError(f"Experiment with run_id {run_id} not found") |
| |
| |
| if experiment.status not in [ExperimentStatus.RUNNING, ExperimentStatus.PENDING]: |
| raise ValueError(f"Cannot save results for experiment in {experiment.status} status") |
| |
| |
| update_data = ExperimentUpdate( |
| result_summary=result_summary, |
| full_result=full_result |
| ) |
| |
| |
| experiment.mark_as_completed(result_summary, full_result) |
| update_data.status = ExperimentStatus.COMPLETED |
| |
| |
| updated_experiment = self.store.update_experiment(run_id, update_data) |
| |
| return updated_experiment |
| |
| def mark_as_failed(self, run_id: str, error_message: str) -> Experiment: |
| """ |
| Mark experiment as failed with error message. |
| |
| Args: |
| run_id: Unique experiment identifier |
| error_message: Error message describing failure |
| |
| Returns: |
| Updated experiment marked as failed |
| |
| Raises: |
| ValueError: If experiment not found |
| """ |
| |
| experiment = self.store.get_experiment(run_id) |
| if not experiment: |
| raise ValueError(f"Experiment with run_id {run_id} not found") |
| |
| |
| update_data = ExperimentUpdate(error_message=error_message) |
| |
| |
| experiment.mark_as_failed(error_message) |
| update_data.status = ExperimentStatus.FAILED |
| |
| |
| updated_experiment = self.store.update_experiment(run_id, update_data) |
| |
| return updated_experiment |
| |
| def get_experiment(self, run_id: str) -> Optional[Experiment]: |
| """ |
| Get experiment by run_id. |
| |
| Args: |
| run_id: Unique experiment identifier |
| |
| Returns: |
| Experiment if found, None otherwise |
| """ |
| return self.store.get_experiment(run_id) |
| |
| def list_experiments(self, filters: Optional[ExperimentFilter] = None) -> ExperimentList: |
| """ |
| List experiments with optional filtering. |
| |
| Args: |
| filters: Optional filters to apply |
| |
| Returns: |
| List of experiments matching filters |
| """ |
| return self.store.list_experiments(filters or ExperimentFilter()) |
| |
| def delete_experiment(self, run_id: str) -> bool: |
| """ |
| Delete experiment by run_id. |
| |
| Args: |
| run_id: Unique experiment identifier |
| |
| Returns: |
| True if deleted, False if not found |
| """ |
| return self.store.delete_experiment(run_id) |
| |
| def compare_experiments(self, run_id_1: str, run_id_2: str) -> ExperimentComparison: |
| """ |
| Compare two experiments. |
| |
| Args: |
| run_id_1: First experiment ID |
| run_id_2: Second experiment ID |
| |
| Returns: |
| Comparison of the two experiments |
| |
| Raises: |
| ValueError: If either experiment not found |
| """ |
| |
| experiment_1 = self.get_experiment(run_id_1) |
| experiment_2 = self.get_experiment(run_id_2) |
| |
| if not experiment_1: |
| raise ValueError(f"Experiment with run_id {run_id_1} not found") |
| if not experiment_2: |
| raise ValueError(f"Experiment with run_id {run_id_2} not found") |
| |
| |
| comparison_metrics = self._calculate_comparison_metrics(experiment_1, experiment_2) |
| |
| return ExperimentComparison( |
| experiment_1=experiment_1, |
| experiment_2=experiment_2, |
| comparison_metrics=comparison_metrics |
| ) |
| |
| def get_experiment_stats(self) -> ExperimentStats: |
| """ |
| Get experiment statistics. |
| |
| Returns: |
| Experiment statistics |
| """ |
| return self.store.get_experiment_stats() |
| |
| def clone_experiment(self, run_id: str, new_name: Optional[str] = None) -> Experiment: |
| """ |
| Clone an experiment with new run_id. |
| |
| Args: |
| run_id: Original experiment ID |
| new_name: Optional new name for cloned experiment |
| |
| Returns: |
| Cloned experiment with new run_id |
| |
| Raises: |
| ValueError: If original experiment not found |
| """ |
| |
| original = self.get_experiment(run_id) |
| if not original: |
| raise ValueError(f"Experiment with run_id {run_id} not found") |
| |
| |
| clone_config = ExperimentCreate( |
| experiment_name=new_name or f"{original.experiment_name} (Clone)" if original.experiment_name else None, |
| description=f"Clone of experiment {run_id}", |
| config_snapshot=original.config_snapshot, |
| priority=original.priority, |
| tags=original.tags.copy(), |
| parent_experiment_id=original.run_id |
| ) |
| |
| return self.create_experiment(clone_config) |
| |
| def _validate_status_transition(self, current_status: ExperimentStatus, new_status: ExperimentStatus): |
| """ |
| Validate that status transition is allowed. |
| |
| Args: |
| current_status: Current experiment status |
| new_status: New status to transition to |
| |
| Raises: |
| ValueError: If transition is not allowed |
| """ |
| |
| allowed_transitions = { |
| ExperimentStatus.PENDING: [ExperimentStatus.RUNNING, ExperimentStatus.CANCELLED], |
| ExperimentStatus.RUNNING: [ExperimentStatus.COMPLETED, ExperimentStatus.FAILED, ExperimentStatus.CANCELLED], |
| ExperimentStatus.COMPLETED: [], |
| ExperimentStatus.FAILED: [], |
| ExperimentStatus.CANCELLED: [] |
| } |
| |
| if new_status not in allowed_transitions.get(current_status, []): |
| raise ValueError(f"Invalid status transition from {current_status} to {new_status}") |
| |
| def _calculate_comparison_metrics(self, exp1: Experiment, exp2: Experiment) -> Dict[str, Dict[str, Any]]: |
| """ |
| Calculate comparison metrics between two experiments. |
| |
| Args: |
| exp1: First experiment |
| exp2: Second experiment |
| |
| Returns: |
| Dictionary of comparison metrics |
| """ |
| metrics = {} |
| |
| |
| if (exp1.result_summary and exp2.result_summary and |
| exp1.status == ExperimentStatus.COMPLETED and |
| exp2.status == ExperimentStatus.COMPLETED): |
| |
| metrics['results'] = { |
| 'robustness_score_diff': exp2.result_summary.robustness_score - exp1.result_summary.robustness_score, |
| 'risk_score_diff': exp2.result_summary.risk_score - exp1.result_summary.risk_score, |
| 'success_rate_diff': exp2.result_summary.success_rate - exp1.result_summary.success_rate, |
| 'execution_time_diff': (exp2.result_summary.execution_time_ms or 0) - (exp1.result_summary.execution_time_ms or 0) |
| } |
| |
| |
| metrics['config'] = { |
| 'same_model': exp1.model_name == exp2.model_name, |
| 'same_dataset': exp1.dataset_name == exp2.dataset_name and exp1.dataset_version == exp2.dataset_version, |
| 'same_attack_types': set(exp1.attack_types) == set(exp2.attack_types), |
| 'prompt_count_diff': exp2.prompt_count - exp1.prompt_count |
| } |
| |
| |
| metrics['timing'] = { |
| 'created_time_diff': (exp2.created_at - exp1.created_at).total_seconds(), |
| 'execution_time_diff': ((exp2.completed_at or exp2.created_at) - (exp1.completed_at or exp1.created_at)).total_seconds() |
| } |
| |
| return metrics |
|
|
|
|
| |
| _experiment_manager = None |
|
|
|
|
| def get_experiment_manager() -> ExperimentManager: |
| """ |
| Get the global experiment manager instance. |
| |
| Returns: |
| Global experiment manager instance |
| """ |
| global _experiment_manager |
| if _experiment_manager is None: |
| _experiment_manager = ExperimentManager() |
| return _experiment_manager |
|
|
|
|
| |
| def create_experiment(config: ExperimentCreate) -> Experiment: |
| """ |
| Create a new experiment. |
| |
| Args: |
| config: Experiment creation configuration |
| |
| Returns: |
| Created experiment |
| """ |
| manager = get_experiment_manager() |
| return manager.create_experiment(config) |
|
|
|
|
| def update_experiment_status(run_id: str, status: ExperimentStatus) -> Experiment: |
| """ |
| Update experiment status. |
| |
| Args: |
| run_id: Unique experiment identifier |
| status: New status |
| |
| Returns: |
| Updated experiment |
| """ |
| manager = get_experiment_manager() |
| return manager.update_status(run_id, status) |
|
|
|
|
| def save_experiment_results(run_id: str, result_summary: ResultSummary, |
| full_result: Optional[Dict[str, Any]] = None) -> Experiment: |
| """ |
| Save experiment results. |
| |
| Args: |
| run_id: Unique experiment identifier |
| result_summary: Summary of results |
| full_result: Complete result data |
| |
| Returns: |
| Updated experiment |
| """ |
| manager = get_experiment_manager() |
| return manager.save_results(run_id, result_summary, full_result) |
|
|
|
|
| def get_experiment_by_id(run_id: str) -> Optional[Experiment]: |
| """ |
| Get experiment by run_id. |
| |
| Args: |
| run_id: Unique experiment identifier |
| |
| Returns: |
| Experiment if found, None otherwise |
| """ |
| manager = get_experiment_manager() |
| return manager.get_experiment(run_id) |
|
|