Upload 50 files
Browse files- backend/api/__init__.py +12 -0
- backend/api/dependencies.py +102 -0
- backend/api/middleware/__init__.py +13 -0
- backend/api/middleware/api_versioning.py +169 -0
- backend/api/middleware/payload_validation.py +247 -0
- backend/api/middleware/plan_enforcement.py +252 -0
- backend/api/middleware/request_limits.py +164 -0
- backend/api/public/__init__.py +9 -0
- backend/api/public/auth.py +275 -0
- backend/api/public/rate_limit.py +368 -0
- backend/api/public/routes.py +409 -0
- backend/api/public/schemas.py +335 -0
- backend/api/public/service.py +389 -0
- backend/api/regulatory_routes.py +685 -0
- backend/api/routes.py +2183 -0
- backend/benchmarking/__init__.py +119 -0
- backend/benchmarking/comparison.py +468 -0
- backend/benchmarking/engine.py +629 -0
- backend/benchmarking/reporter.py +623 -0
- backend/benchmarking/schemas.py +312 -0
- backend/benchmarking/statistics.py +494 -0
- backend/core/config.py +327 -0
- backend/core/dataset_loader.py +624 -0
- backend/core/dataset_schemas.py +265 -0
- backend/core/exceptions.py +248 -0
- backend/core/model_registry.py +642 -0
- backend/core/or edit again +0 -0
- backend/core/orchestrator.py +955 -0
- backend/core/qu the routes.ota.py +179 -0
- backend/core/quota.py +179 -0
- backend/monitoring/__init__.py +96 -0
- backend/monitoring/alerting.py +329 -0
- backend/monitoring/drift_detection.py +427 -0
- backend/monitoring/pipeline.py +367 -0
- backend/monitoring/schemas.py +182 -0
- backend/monitoring/streaming_evaluator.py +422 -0
- backend/queue/__init__.py +105 -0
- backend/queue/consumer.py +294 -0
- backend/queue/job_schema.py +321 -0
- backend/queue/producer.py +278 -0
- backend/queue/scheduler.py +464 -0
- backend/queue/status_tracker.py +443 -0
- backend/queue/worker_registry.py +564 -0
- backend/queue/worker_schema.py +160 -0
- backend/scheduler/__init__.py +33 -0
- backend/scheduler/cost_model.py +278 -0
- backend/scheduler/priority_engine.py +368 -0
- backend/scheduler/resource_allocator.py +366 -0
- backend/scheduler/scheduling_policy.py +338 -0
- backend/scheduler/usage_tracker.py +450 -0
backend/api/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Module
|
| 3 |
+
|
| 4 |
+
FastAPI routes and dependencies.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from backend.api import routes, dependencies
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"routes",
|
| 11 |
+
"dependencies",
|
| 12 |
+
]
|
backend/api/dependencies.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Dependencies
|
| 3 |
+
|
| 4 |
+
Dependency injection functions for the API.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import AsyncGenerator, Optional
|
| 8 |
+
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
|
| 11 |
+
from backend.core.config import settings
|
| 12 |
+
from backend.core.model_registry import BaseModelExecutor, TransformersExecutor, model_registry
|
| 13 |
+
from backend.core.orchestrator import EvaluationOrchestrator, get_orchestrator
|
| 14 |
+
from backend.db.session import get_db_session
|
| 15 |
+
from backend.logging.logger import StructuredLogger
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# =============================================================================
|
| 19 |
+
# Database Dependencies
|
| 20 |
+
# =============================================================================
|
| 21 |
+
|
| 22 |
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 23 |
+
"""
|
| 24 |
+
Get database session dependency.
|
| 25 |
+
|
| 26 |
+
Yields:
|
| 27 |
+
Async SQLAlchemy session
|
| 28 |
+
"""
|
| 29 |
+
async for session in get_db_session():
|
| 30 |
+
yield session
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# =============================================================================
|
| 34 |
+
# Model Dependencies
|
| 35 |
+
# =============================================================================
|
| 36 |
+
|
| 37 |
+
async def get_model_executor(
|
| 38 |
+
model_name: Optional[str] = None,
|
| 39 |
+
) -> BaseModelExecutor:
|
| 40 |
+
"""
|
| 41 |
+
Get model executor dependency.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
model_name: Optional model name override
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
Model executor instance
|
| 48 |
+
"""
|
| 49 |
+
return model_registry.get_executor(
|
| 50 |
+
model_name=model_name or settings.default_model,
|
| 51 |
+
executor_type=TransformersExecutor,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# =============================================================================
|
| 56 |
+
# Orchestrator Dependencies
|
| 57 |
+
# =============================================================================
|
| 58 |
+
|
| 59 |
+
# Re-export get_orchestrator from orchestrator module for dependency injection
|
| 60 |
+
# This allows routes.py to import it from dependencies
|
| 61 |
+
__all__ = ["get_orchestrator"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# =============================================================================
|
| 65 |
+
# Logger Dependencies
|
| 66 |
+
# =============================================================================
|
| 67 |
+
|
| 68 |
+
def get_logger(
|
| 69 |
+
name: str,
|
| 70 |
+
run_id: Optional[str] = None,
|
| 71 |
+
component: Optional[str] = None,
|
| 72 |
+
) -> StructuredLogger:
|
| 73 |
+
"""
|
| 74 |
+
Get logger dependency.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
name: Logger name
|
| 78 |
+
run_id: Optional run ID
|
| 79 |
+
component: Optional component name
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
StructuredLogger instance
|
| 83 |
+
"""
|
| 84 |
+
return StructuredLogger(
|
| 85 |
+
name=name,
|
| 86 |
+
run_id=run_id,
|
| 87 |
+
component=component or name,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# =============================================================================
|
| 92 |
+
# Config Dependencies
|
| 93 |
+
# =============================================================================
|
| 94 |
+
|
| 95 |
+
def get_settings() -> settings:
|
| 96 |
+
"""
|
| 97 |
+
Get settings dependency.
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Application settings
|
| 101 |
+
"""
|
| 102 |
+
return settings
|
backend/api/middleware/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Middleware for AegisLM
|
| 3 |
+
|
| 4 |
+
Provides middleware components for request size limits and payload validation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from backend.api.middleware.request_limits import RequestSizeLimitMiddleware
|
| 8 |
+
from backend.api.middleware.payload_validation import PayloadValidationMiddleware
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"RequestSizeLimitMiddleware",
|
| 12 |
+
"PayloadValidationMiddleware",
|
| 13 |
+
]
|
backend/api/middleware/api_versioning.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Versioning Middleware
|
| 3 |
+
|
| 4 |
+
Ensures consistent API versioning across all endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
from typing import Optional, Callable
|
| 9 |
+
from fastapi import Request, Response
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 12 |
+
|
| 13 |
+
from backend.logging.logger import get_logger
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# Current API version
|
| 17 |
+
CURRENT_VERSION = "1.0.0"
|
| 18 |
+
SUPPORTED_VERSIONS = ["1.0.0", "1.0.0-beta", "0.9.0"]
|
| 19 |
+
|
| 20 |
+
# Version compatibility matrix
|
| 21 |
+
VERSION_COMPATIBILITY = {
|
| 22 |
+
"1.0.0": {
|
| 23 |
+
"min_client": "1.0.0",
|
| 24 |
+
"features": ["evaluation", "certification", "monitoring", "leaderboard", "risk_passport"],
|
| 25 |
+
"breaking": [],
|
| 26 |
+
},
|
| 27 |
+
"1.0.0-beta": {
|
| 28 |
+
"min_client": "0.9.0",
|
| 29 |
+
"features": ["evaluation", "certification", "monitoring", "leaderboard"],
|
| 30 |
+
"breaking": ["risk_passport"],
|
| 31 |
+
},
|
| 32 |
+
"0.9.0": {
|
| 33 |
+
"min_client": "0.9.0",
|
| 34 |
+
"features": ["evaluation", "certification"],
|
| 35 |
+
"breaking": ["monitoring", "leaderboard", "risk_passport"],
|
| 36 |
+
},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class APIVersioningMiddleware(BaseHTTPMiddleware):
|
| 41 |
+
"""
|
| 42 |
+
Middleware to handle API versioning.
|
| 43 |
+
|
| 44 |
+
- Extracts version from Accept-Version header or URL path
|
| 45 |
+
- Validates version compatibility
|
| 46 |
+
- Adds version headers to responses
|
| 47 |
+
- Handles deprecation warnings
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, app, default_version: str = CURRENT_VERSION):
|
| 51 |
+
super().__init__(app)
|
| 52 |
+
self.default_version = default_version
|
| 53 |
+
self.logger = get_logger("api.versioning")
|
| 54 |
+
|
| 55 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 56 |
+
# Extract version from header
|
| 57 |
+
client_version = request.headers.get("Accept-Version")
|
| 58 |
+
|
| 59 |
+
# Extract version from URL path if header not present
|
| 60 |
+
if not client_version:
|
| 61 |
+
client_version = self._extract_version_from_path(request.url.path)
|
| 62 |
+
|
| 63 |
+
# Use default if no version specified
|
| 64 |
+
if not client_version:
|
| 65 |
+
client_version = self.default_version
|
| 66 |
+
|
| 67 |
+
# Validate version
|
| 68 |
+
is_supported, version_to_use = self._validate_version(client_version)
|
| 69 |
+
|
| 70 |
+
# Add version info to request state
|
| 71 |
+
request.state.api_version = version_to_use
|
| 72 |
+
request.state.client_version = client_version
|
| 73 |
+
|
| 74 |
+
# Process request
|
| 75 |
+
response = await call_next(request)
|
| 76 |
+
|
| 77 |
+
# Add version headers to response
|
| 78 |
+
response.headers["X-API-Version"] = version_to_use
|
| 79 |
+
response.headers["X-Supported-Versions"] = ",".join(SUPPORTED_VERSIONS)
|
| 80 |
+
|
| 81 |
+
# Add deprecation warning if needed
|
| 82 |
+
if self._is_deprecated(version_to_use):
|
| 83 |
+
response.headers["X-API-Deprecated"] = "true"
|
| 84 |
+
response.headers["X-API-Deprecation-Date"] = self._get_deprecation_date(version_to_use)
|
| 85 |
+
|
| 86 |
+
# Return appropriate response
|
| 87 |
+
if not is_supported:
|
| 88 |
+
return JSONResponse(
|
| 89 |
+
status_code=400,
|
| 90 |
+
content={
|
| 91 |
+
"error": "Unsupported API Version",
|
| 92 |
+
"message": f"Version {client_version} is not supported",
|
| 93 |
+
"supported_versions": SUPPORTED_VERSIONS,
|
| 94 |
+
"current_version": CURRENT_VERSION,
|
| 95 |
+
},
|
| 96 |
+
headers={
|
| 97 |
+
"X-API-Version": version_to_use,
|
| 98 |
+
"X-Supported-Versions": ",".join(SUPPORTED_VERSIONS),
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
return response
|
| 103 |
+
|
| 104 |
+
def _extract_version_from_path(self, path: str) -> Optional[str]:
|
| 105 |
+
"""Extract version from URL path like /api/v1/..."""
|
| 106 |
+
match = re.match(r'/api/(v\d+(?:\.\d+)?(?:-[a-z]+)?)', path)
|
| 107 |
+
if match:
|
| 108 |
+
version = match.group(1).replace("v", "")
|
| 109 |
+
return version
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
def _validate_version(self, version: str) -> tuple[bool, str]:
|
| 113 |
+
"""Validate if version is supported, return (is_supported, effective_version)"""
|
| 114 |
+
if version in SUPPORTED_VERSIONS:
|
| 115 |
+
return True, version
|
| 116 |
+
|
| 117 |
+
# Try to find compatible version
|
| 118 |
+
# For now, just return current version if not found
|
| 119 |
+
return False, CURRENT_VERSION
|
| 120 |
+
|
| 121 |
+
def _is_deprecated(self, version: str) -> bool:
|
| 122 |
+
"""Check if version is deprecated"""
|
| 123 |
+
return version in ["0.9.0"]
|
| 124 |
+
|
| 125 |
+
def _get_deprecation_date(self, version: str) -> str:
|
| 126 |
+
"""Get deprecation date for version"""
|
| 127 |
+
deprecation_dates = {
|
| 128 |
+
"0.9.0": "2024-12-31",
|
| 129 |
+
}
|
| 130 |
+
return deprecation_dates.get(version, "Unknown")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def get_api_version(request: Request) -> str:
|
| 134 |
+
"""
|
| 135 |
+
Get the API version for a request.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
request: FastAPI request object
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
API version string
|
| 142 |
+
"""
|
| 143 |
+
return getattr(request.state, "api_version", CURRENT_VERSION)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def require_feature(request: Request, feature: str) -> bool:
|
| 147 |
+
"""
|
| 148 |
+
Check if a feature is available for the client's API version.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
request: FastAPI request object
|
| 152 |
+
feature: Feature name to check
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
True if feature is available
|
| 156 |
+
"""
|
| 157 |
+
version = get_api_version(request)
|
| 158 |
+
config = VERSION_COMPATIBILITY.get(version, VERSION_COMPATIBILITY[CURRENT_VERSION])
|
| 159 |
+
return feature in config.get("features", [])
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
__all__ = [
|
| 163 |
+
"APIVersioningMiddleware",
|
| 164 |
+
"CURRENT_VERSION",
|
| 165 |
+
"SUPPORTED_VERSIONS",
|
| 166 |
+
"VERSION_COMPATIBILITY",
|
| 167 |
+
"get_api_version",
|
| 168 |
+
"require_feature",
|
| 169 |
+
]
|
backend/api/middleware/payload_validation.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Payload Validation Middleware for AegisLM
|
| 3 |
+
|
| 4 |
+
Provides middleware to validate job payloads before processing.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
from enum import Enum
|
| 9 |
+
|
| 10 |
+
from fastapi import Request, HTTPException
|
| 11 |
+
from pydantic import BaseModel, Field, validator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class EvaluationMode(str, Enum):
|
| 15 |
+
"""Evaluation modes."""
|
| 16 |
+
LIGHTWEIGHT = "lightweight"
|
| 17 |
+
FULL = "full"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AttackType(str, Enum):
|
| 21 |
+
"""Valid attack types."""
|
| 22 |
+
INJECTION = "injection"
|
| 23 |
+
JAILBREAK = "jailbreak"
|
| 24 |
+
BIAS_TRIGGER = "bias_trigger"
|
| 25 |
+
CONTEXT_POISON = "context_poison"
|
| 26 |
+
ROLE_CONFUSION = "role_confusion"
|
| 27 |
+
CHAINING = "chaining"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class MutationType(str, Enum):
|
| 31 |
+
"""Valid mutation types."""
|
| 32 |
+
SYNONYM = "synonym"
|
| 33 |
+
ROLE_SWAP = "role_swap"
|
| 34 |
+
CONTEXT_OBFUSCATION = "context_obfuscation"
|
| 35 |
+
MULTI_HOP = "multi_hop"
|
| 36 |
+
PARAPHRASE = "paraphrase"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class JobPayloadSchema(BaseModel):
|
| 40 |
+
"""Schema for job payload validation."""
|
| 41 |
+
|
| 42 |
+
model_name: str = Field(..., min_length=1, max_length=255)
|
| 43 |
+
model_version: str = Field(..., min_length=1, max_length=100)
|
| 44 |
+
dataset_name: str = Field(..., min_length=1, max_length=255)
|
| 45 |
+
dataset_version: str = Field(..., min_length=1, max_length=100)
|
| 46 |
+
|
| 47 |
+
# Evaluation settings
|
| 48 |
+
evaluation_mode: EvaluationMode = EvaluationMode.FULL
|
| 49 |
+
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
| 50 |
+
max_tokens: int = Field(default=512, ge=1, le=4096)
|
| 51 |
+
|
| 52 |
+
# Attack settings
|
| 53 |
+
attack_types: Optional[List[str]] = None
|
| 54 |
+
mutation_enabled: bool = True
|
| 55 |
+
mutation_depth: int = Field(default=1, ge=0, le=5)
|
| 56 |
+
|
| 57 |
+
# Batch settings
|
| 58 |
+
batch_size: int = Field(default=10, ge=1, le=100)
|
| 59 |
+
max_samples: Optional[int] = Field(default=None, ge=1)
|
| 60 |
+
|
| 61 |
+
@validator("attack_types")
|
| 62 |
+
def validate_attack_types(cls, v):
|
| 63 |
+
if v is not None:
|
| 64 |
+
valid_attacks = [a.value for a in AttackType]
|
| 65 |
+
for attack in v:
|
| 66 |
+
if attack not in valid_attacks:
|
| 67 |
+
raise ValueError(f"Invalid attack type: {attack}")
|
| 68 |
+
return v
|
| 69 |
+
|
| 70 |
+
@validator("dataset_name")
|
| 71 |
+
def validate_dataset_name(cls, v):
|
| 72 |
+
# Check against known datasets
|
| 73 |
+
allowed_datasets = ["advbench", "truthfulqa", "aegislm-harmful-queries"]
|
| 74 |
+
if v not in allowed_datasets:
|
| 75 |
+
# Allow for custom datasets but warn
|
| 76 |
+
pass
|
| 77 |
+
return v
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class PayloadValidator:
|
| 81 |
+
"""
|
| 82 |
+
Validates job payloads for security and integrity.
|
| 83 |
+
|
| 84 |
+
Checks:
|
| 85 |
+
- Required fields present
|
| 86 |
+
- Field values within acceptable ranges
|
| 87 |
+
- Dataset and model versions exist
|
| 88 |
+
- Attack types are valid
|
| 89 |
+
- Weights sum to 1.0 (if provided)
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
# Valid dataset names
|
| 93 |
+
ALLOWED_DATASETS = ["advbench", "truthfulqa", "aegislm-harmful-queries"]
|
| 94 |
+
|
| 95 |
+
# Valid attack types
|
| 96 |
+
ALLOWED_ATTACKS = [a.value for a in AttackType]
|
| 97 |
+
|
| 98 |
+
# Valid mutation types
|
| 99 |
+
ALLOWED_MUTATIONS = [m.value for m in MutationType]
|
| 100 |
+
|
| 101 |
+
# Max mutation depth
|
| 102 |
+
MAX_MUTATION_DEPTH = 5
|
| 103 |
+
|
| 104 |
+
@classmethod
|
| 105 |
+
def validate_payload(cls, payload: Dict[str, Any]) -> JobPayloadSchema:
|
| 106 |
+
"""
|
| 107 |
+
Validate a job payload.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
payload: The payload to validate
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Validated payload as JobPayloadSchema
|
| 114 |
+
|
| 115 |
+
Raises:
|
| 116 |
+
HTTPException: If validation fails
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
validated = JobPayloadSchema(**payload)
|
| 120 |
+
return validated
|
| 121 |
+
except Exception as e:
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
status_code=400,
|
| 124 |
+
detail={
|
| 125 |
+
"error": "invalid_payload",
|
| 126 |
+
"message": str(e),
|
| 127 |
+
}
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
@classmethod
|
| 131 |
+
def validate_model_version(cls, model_name: str, model_version: str) -> bool:
|
| 132 |
+
"""
|
| 133 |
+
Validate that a model version exists.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
model_name: Name of the model
|
| 137 |
+
model_version: Version of the model
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
True if valid, False otherwise
|
| 141 |
+
"""
|
| 142 |
+
# In a real implementation, this would check against the model registry
|
| 143 |
+
# For now, we accept any model/version but could add validation
|
| 144 |
+
return True
|
| 145 |
+
|
| 146 |
+
@classmethod
|
| 147 |
+
def validate_dataset_version(cls, dataset_name: str, dataset_version: str) -> bool:
|
| 148 |
+
"""
|
| 149 |
+
Validate that a dataset version exists.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
dataset_name: Name of the dataset
|
| 153 |
+
dataset_version: Version of the dataset
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
True if valid, False otherwise
|
| 157 |
+
"""
|
| 158 |
+
# In a real implementation, this would check against the dataset registry
|
| 159 |
+
# For now, we accept any dataset/version but could add validation
|
| 160 |
+
return True
|
| 161 |
+
|
| 162 |
+
@classmethod
|
| 163 |
+
def validate_weights(cls, weights: Dict[str, float]) -> bool:
|
| 164 |
+
"""
|
| 165 |
+
Validate that scoring weights sum to 1.0.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
weights: Dictionary of metric weights
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
True if valid
|
| 172 |
+
|
| 173 |
+
Raises:
|
| 174 |
+
HTTPException: If weights don't sum to 1.0
|
| 175 |
+
"""
|
| 176 |
+
required_keys = {"hallucination", "toxicity", "bias", "confidence"}
|
| 177 |
+
|
| 178 |
+
if set(weights.keys()) != required_keys:
|
| 179 |
+
raise HTTPException(
|
| 180 |
+
status_code=400,
|
| 181 |
+
detail={
|
| 182 |
+
"error": "invalid_weights",
|
| 183 |
+
"message": f"Weights must include exactly: {required_keys}",
|
| 184 |
+
}
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
total = sum(weights.values())
|
| 188 |
+
if abs(total - 1.0) > 1e-6:
|
| 189 |
+
raise HTTPException(
|
| 190 |
+
status_code=400,
|
| 191 |
+
detail={
|
| 192 |
+
"error": "invalid_weights",
|
| 193 |
+
"message": f"Weights must sum to 1.0, got {total}",
|
| 194 |
+
}
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
return True
|
| 198 |
+
|
| 199 |
+
@classmethod
|
| 200 |
+
def validate_mutation_depth(cls, depth: int) -> bool:
|
| 201 |
+
"""
|
| 202 |
+
Validate mutation depth is within allowed range.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
depth: Mutation depth
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
True if valid
|
| 209 |
+
|
| 210 |
+
Raises:
|
| 211 |
+
HTTPException: If depth is out of range
|
| 212 |
+
"""
|
| 213 |
+
if depth < 0 or depth > cls.MAX_MUTATION_DEPTH:
|
| 214 |
+
raise HTTPException(
|
| 215 |
+
status_code=400,
|
| 216 |
+
detail={
|
| 217 |
+
"error": "invalid_mutation_depth",
|
| 218 |
+
"message": f"Mutation depth must be between 0 and {cls.MAX_MUTATION_DEPTH}",
|
| 219 |
+
}
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
return True
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def validate_job_payload(request: Request) -> Dict[str, Any]:
|
| 226 |
+
"""
|
| 227 |
+
FastAPI dependency to validate job payloads.
|
| 228 |
+
|
| 229 |
+
Usage:
|
| 230 |
+
@router.post("/jobs")
|
| 231 |
+
async def create_job(
|
| 232 |
+
payload: dict = Depends(validate_job_payload)
|
| 233 |
+
):
|
| 234 |
+
...
|
| 235 |
+
"""
|
| 236 |
+
try:
|
| 237 |
+
body = await request.json()
|
| 238 |
+
except Exception:
|
| 239 |
+
raise HTTPException(
|
| 240 |
+
status_code=400,
|
| 241 |
+
detail={
|
| 242 |
+
"error": "invalid_json",
|
| 243 |
+
"message": "Request body must be valid JSON",
|
| 244 |
+
}
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
return PayloadValidator.validate_payload(body)
|
backend/api/middleware/plan_enforcement.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Plan Enforcement Middleware
|
| 3 |
+
|
| 4 |
+
Middleware to enforce subscription plan limits before job submission.
|
| 5 |
+
Checks quotas and feature availability before allowing operations.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Callable, Optional
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, Request, Response
|
| 13 |
+
from fastapi.responses import JSONResponse
|
| 14 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 15 |
+
from starlette.types import ASGIApp
|
| 16 |
+
|
| 17 |
+
from saas.schemas import PlanType
|
| 18 |
+
from saas.tenant_plan_enforcement import (
|
| 19 |
+
PlanEnforcer,
|
| 20 |
+
get_plan_enforcer,
|
| 21 |
+
PlanViolation,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class PlanEnforcementMiddleware(BaseHTTPMiddleware):
|
| 28 |
+
"""
|
| 29 |
+
Middleware to enforce subscription plan limits.
|
| 30 |
+
|
| 31 |
+
Checks before job submission:
|
| 32 |
+
- Plan tier
|
| 33 |
+
- Monthly quota
|
| 34 |
+
- GPU budget
|
| 35 |
+
- Feature eligibility
|
| 36 |
+
|
| 37 |
+
Returns 403 if limits exceeded.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
app: ASGIApp,
|
| 43 |
+
enforcer: Optional[PlanEnforcer] = None,
|
| 44 |
+
):
|
| 45 |
+
"""
|
| 46 |
+
Initialize the middleware.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
app: ASGI application
|
| 50 |
+
enforcer: Plan enforcer instance (optional)
|
| 51 |
+
"""
|
| 52 |
+
super().__init__(app)
|
| 53 |
+
self.enforcer = enforcer or get_plan_enforcer()
|
| 54 |
+
|
| 55 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 56 |
+
"""
|
| 57 |
+
Process the request and enforce plan limits.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
request: The incoming request
|
| 61 |
+
call_next: The next middleware or route handler
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Response or error if plan limit exceeded
|
| 65 |
+
"""
|
| 66 |
+
# Skip enforcement for health checks and public endpoints
|
| 67 |
+
if self._should_skip_enforcement(request):
|
| 68 |
+
return await call_next(request)
|
| 69 |
+
|
| 70 |
+
# Get tenant info from request state (set by auth middleware)
|
| 71 |
+
tenant_id = getattr(request.state, "tenant_id", None)
|
| 72 |
+
plan_type = getattr(request.state, "plan_type", None)
|
| 73 |
+
|
| 74 |
+
if not tenant_id or not plan_type:
|
| 75 |
+
# Let auth middleware handle missing tenant
|
| 76 |
+
return await call_next(request)
|
| 77 |
+
|
| 78 |
+
# Check if this is a job submission endpoint
|
| 79 |
+
if self._is_job_submission(request):
|
| 80 |
+
try:
|
| 81 |
+
await self._check_job_submission(request, tenant_id, plan_type)
|
| 82 |
+
except PlanViolation as e:
|
| 83 |
+
return JSONResponse(
|
| 84 |
+
status_code=403,
|
| 85 |
+
content=e.to_dict()
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Check feature access for specific endpoints
|
| 89 |
+
if self._requires_feature_check(request):
|
| 90 |
+
try:
|
| 91 |
+
await self._check_feature_access(request, tenant_id, plan_type)
|
| 92 |
+
except PlanViolation as e:
|
| 93 |
+
return JSONResponse(
|
| 94 |
+
status_code=403,
|
| 95 |
+
content=e.to_dict()
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return await call_next(request)
|
| 99 |
+
|
| 100 |
+
def _should_skip_enforcement(self, request: Request) -> bool:
|
| 101 |
+
"""Check if enforcement should be skipped for this request."""
|
| 102 |
+
# Skip health checks
|
| 103 |
+
if request.url.path in ["/health", "/ready", "/healthz"]:
|
| 104 |
+
return True
|
| 105 |
+
|
| 106 |
+
# Skip public endpoints
|
| 107 |
+
if request.url.path.startswith("/public"):
|
| 108 |
+
return True
|
| 109 |
+
|
| 110 |
+
# Skip docs
|
| 111 |
+
if request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"):
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
def _is_job_submission(self, request: Request) -> bool:
|
| 117 |
+
"""Check if this is a job submission request."""
|
| 118 |
+
# POST to /api/v1/jobs or similar endpoints
|
| 119 |
+
if request.method != "POST":
|
| 120 |
+
return False
|
| 121 |
+
|
| 122 |
+
path = request.url.path
|
| 123 |
+
return "/jobs" in path or "/evaluations" in path
|
| 124 |
+
|
| 125 |
+
def _requires_feature_check(self, request: Request) -> bool:
|
| 126 |
+
"""Check if this endpoint requires feature access check."""
|
| 127 |
+
path = request.url.path
|
| 128 |
+
|
| 129 |
+
# Check for feature-gated endpoints
|
| 130 |
+
feature_gated_paths = [
|
| 131 |
+
"/adaptive", # Adaptive adversarial
|
| 132 |
+
"/monitoring/real-time", # Real-time monitoring
|
| 133 |
+
"/certification/export", # Certification export
|
| 134 |
+
"/audit/export", # Audit export
|
| 135 |
+
"/compliance/reports", # Custom compliance reports
|
| 136 |
+
"/ci/integration", # CI integration
|
| 137 |
+
"/webhooks", # Webhooks
|
| 138 |
+
"/api/v1/full", # Full API access
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
return any(path.startswith(p) for p in feature_gated_paths)
|
| 142 |
+
|
| 143 |
+
async def _check_job_submission(
|
| 144 |
+
self,
|
| 145 |
+
request: Request,
|
| 146 |
+
tenant_id: uuid.UUID,
|
| 147 |
+
plan_type: PlanType,
|
| 148 |
+
) -> None:
|
| 149 |
+
"""
|
| 150 |
+
Check if job submission is allowed.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
request: The incoming request
|
| 154 |
+
tenant_id: The tenant identifier
|
| 155 |
+
plan_type: The tenant's plan type
|
| 156 |
+
|
| 157 |
+
Raises:
|
| 158 |
+
PlanViolation: If quota exceeded
|
| 159 |
+
"""
|
| 160 |
+
# Parse request body to get sample count and estimated GPU hours
|
| 161 |
+
# This is a simplified version - in production, you'd parse the actual request
|
| 162 |
+
sample_count = 1 # Default
|
| 163 |
+
estimated_gpu_hours = 0.1 # Default estimate
|
| 164 |
+
|
| 165 |
+
# Get current concurrent jobs from request state
|
| 166 |
+
current_jobs = getattr(request.state, "current_concurrent_jobs", 0)
|
| 167 |
+
|
| 168 |
+
is_allowed, error_message = await self.enforcer.check_job_submission(
|
| 169 |
+
tenant_id=tenant_id,
|
| 170 |
+
plan_type=plan_type,
|
| 171 |
+
sample_count=sample_count,
|
| 172 |
+
estimated_gpu_hours=estimated_gpu_hours,
|
| 173 |
+
current_concurrent_jobs=current_jobs,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
if not is_allowed:
|
| 177 |
+
raise PlanViolation(
|
| 178 |
+
violation_type="evaluation_limit_exceeded",
|
| 179 |
+
message=error_message or "Job submission blocked due to quota limits",
|
| 180 |
+
current_value=current_jobs,
|
| 181 |
+
quota=plan_type.value,
|
| 182 |
+
plan_type=plan_type,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
async def _check_feature_access(
|
| 186 |
+
self,
|
| 187 |
+
request: Request,
|
| 188 |
+
tenant_id: uuid.UUID,
|
| 189 |
+
plan_type: PlanType,
|
| 190 |
+
) -> None:
|
| 191 |
+
"""
|
| 192 |
+
Check if tenant has access to required features.
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
request: The incoming request
|
| 196 |
+
tenant_id: The tenant identifier
|
| 197 |
+
plan_type: The tenant's plan type
|
| 198 |
+
|
| 199 |
+
Raises:
|
| 200 |
+
PlanViolation: If feature not available
|
| 201 |
+
"""
|
| 202 |
+
from saas.feature_flags import (
|
| 203 |
+
FeatureFlag,
|
| 204 |
+
get_feature_flag_manager,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
path = request.url.path
|
| 208 |
+
|
| 209 |
+
# Map paths to required features
|
| 210 |
+
feature_map = {
|
| 211 |
+
"/adaptive": FeatureFlag.ADAPTIVE_ADVERSARIAL,
|
| 212 |
+
"/monitoring/real-time": FeatureFlag.REAL_TIME_MONITORING,
|
| 213 |
+
"/certification/export": FeatureFlag.CERTIFICATION_EXPORT,
|
| 214 |
+
"/audit/export": FeatureFlag.AUDIT_EXPORT,
|
| 215 |
+
"/compliance/reports": FeatureFlag.CUSTOM_COMPLIANCE_REPORTS,
|
| 216 |
+
"/ci/integration": FeatureFlag.CI_INTEGRATION,
|
| 217 |
+
"/webhooks": FeatureFlag.WEBHOOKS,
|
| 218 |
+
"/api/v1/full": FeatureFlag.FULL_API_ACCESS,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
feature_manager = get_feature_flag_manager()
|
| 222 |
+
|
| 223 |
+
for path_prefix, feature in feature_map.items():
|
| 224 |
+
if path.startswith(path_prefix):
|
| 225 |
+
if not feature_manager.is_feature_enabled(tenant_id, plan_type, feature):
|
| 226 |
+
raise PlanViolation(
|
| 227 |
+
violation_type="feature_not_available",
|
| 228 |
+
message=f"Feature '{feature.value}' is not available on your plan",
|
| 229 |
+
current_value=0,
|
| 230 |
+
quota=0,
|
| 231 |
+
plan_type=plan_type,
|
| 232 |
+
recommended_action=f"Upgrade to a higher plan to access this feature",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def get_plan_enforcement_middleware(
|
| 237 |
+
app: FastAPI,
|
| 238 |
+
enforcer: Optional[PlanEnforcer] = None,
|
| 239 |
+
) -> PlanEnforcementMiddleware:
|
| 240 |
+
"""
|
| 241 |
+
Create and add plan enforcement middleware to app.
|
| 242 |
+
|
| 243 |
+
Args:
|
| 244 |
+
app: FastAPI application
|
| 245 |
+
enforcer: Optional plan enforcer
|
| 246 |
+
|
| 247 |
+
Returns:
|
| 248 |
+
Configured middleware instance
|
| 249 |
+
"""
|
| 250 |
+
middleware = PlanEnforcementMiddleware(app, enforcer)
|
| 251 |
+
app.add_middleware(PlanEnforcementMiddleware, enforcer=enforcer)
|
| 252 |
+
return middleware
|
backend/api/middleware/request_limits.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Request Size Limits Middleware for AegisLM
|
| 3 |
+
|
| 4 |
+
Provides middleware to enforce request size limits for API protection.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from typing import Callable, Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, Request, Response
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 13 |
+
from starlette.types import ASGIApp
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
| 17 |
+
"""
|
| 18 |
+
Middleware to enforce maximum request size limits.
|
| 19 |
+
|
| 20 |
+
Protects against:
|
| 21 |
+
- Denial of Service (DoS) attacks via large payloads
|
| 22 |
+
- Memory exhaustion from oversized requests
|
| 23 |
+
- Buffer overflow attacks
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
app: ASGIApp,
|
| 29 |
+
max_request_size_bytes: Optional[int] = None,
|
| 30 |
+
):
|
| 31 |
+
"""
|
| 32 |
+
Initialize the middleware.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
app: ASGI application
|
| 36 |
+
max_request_size_bytes: Maximum request size in bytes.
|
| 37 |
+
Defaults to 10MB if not specified.
|
| 38 |
+
"""
|
| 39 |
+
super().__init__(app)
|
| 40 |
+
self.max_request_size_bytes = max_request_size_bytes or self._get_default_max_size()
|
| 41 |
+
|
| 42 |
+
def _get_default_max_size(self) -> int:
|
| 43 |
+
"""Get default max request size from environment or use default."""
|
| 44 |
+
# Default: 10MB
|
| 45 |
+
default_size = 10 * 1024 * 1024 # 10 MB
|
| 46 |
+
|
| 47 |
+
env_size = os.getenv("AEGISLM_MAX_REQUEST_SIZE_BYTES")
|
| 48 |
+
if env_size:
|
| 49 |
+
try:
|
| 50 |
+
return int(env_size)
|
| 51 |
+
except ValueError:
|
| 52 |
+
pass
|
| 53 |
+
|
| 54 |
+
return default_size
|
| 55 |
+
|
| 56 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 57 |
+
"""
|
| 58 |
+
Process the request and enforce size limits.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
request: The incoming request
|
| 62 |
+
call_next: The next middleware or route handler
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Response or error if request is too large
|
| 66 |
+
"""
|
| 67 |
+
# Get content length
|
| 68 |
+
content_length = request.headers.get("content-length")
|
| 69 |
+
|
| 70 |
+
if content_length:
|
| 71 |
+
try:
|
| 72 |
+
content_length = int(content_length)
|
| 73 |
+
|
| 74 |
+
if content_length > self.max_request_size_bytes:
|
| 75 |
+
return JSONResponse(
|
| 76 |
+
status_code=413, # Payload Too Large
|
| 77 |
+
content={
|
| 78 |
+
"error": "request_too_large",
|
| 79 |
+
"message": f"Request body exceeds maximum allowed size of {self.max_request_size_bytes} bytes",
|
| 80 |
+
"max_size_bytes": self.max_request_size_bytes,
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
except ValueError:
|
| 84 |
+
pass
|
| 85 |
+
|
| 86 |
+
# Also check for Content-Length mismatch during streaming
|
| 87 |
+
try:
|
| 88 |
+
body = await request.body()
|
| 89 |
+
if len(body) > self.max_request_size_bytes:
|
| 90 |
+
return JSONResponse(
|
| 91 |
+
status_code=413, # Payload Too Large
|
| 92 |
+
content={
|
| 93 |
+
"error": "request_too_large",
|
| 94 |
+
"message": f"Request body exceeds maximum allowed size of {self.max_request_size_bytes} bytes",
|
| 95 |
+
"max_size_bytes": self.max_request_size_bytes,
|
| 96 |
+
}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# Re-create request with body for downstream handlers
|
| 100 |
+
async def receive():
|
| 101 |
+
return {"type": "http.request", "body": body}
|
| 102 |
+
|
| 103 |
+
request._receive = receive
|
| 104 |
+
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
return await call_next(request)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# =============================================================================
|
| 112 |
+
# Additional security limits
|
| 113 |
+
# =============================================================================
|
| 114 |
+
|
| 115 |
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
| 116 |
+
"""
|
| 117 |
+
Middleware to add security headers to responses.
|
| 118 |
+
|
| 119 |
+
Adds headers for:
|
| 120 |
+
- Content Security Policy
|
| 121 |
+
- X-Frame-Options
|
| 122 |
+
- X-Content-Type-Options
|
| 123 |
+
- Strict-Transport-Security
|
| 124 |
+
- X-XSS-Protection
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 128 |
+
"""
|
| 129 |
+
Add security headers to the response.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
request: The incoming request
|
| 133 |
+
call_next: The next middleware or route handler
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Response with security headers
|
| 137 |
+
"""
|
| 138 |
+
response = await call_next(request)
|
| 139 |
+
|
| 140 |
+
# Add security headers
|
| 141 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 142 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 143 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 144 |
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
| 145 |
+
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
| 146 |
+
|
| 147 |
+
return response
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def get_request_size_limit() -> int:
|
| 151 |
+
"""
|
| 152 |
+
Get the configured request size limit.
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
Maximum request size in bytes
|
| 156 |
+
"""
|
| 157 |
+
default_size = 10 * 1024 * 1024 # 10 MB
|
| 158 |
+
env_size = os.getenv("AEGISLM_MAX_REQUEST_SIZE_BYTES")
|
| 159 |
+
if env_size:
|
| 160 |
+
try:
|
| 161 |
+
return int(env_size)
|
| 162 |
+
except ValueError:
|
| 163 |
+
pass
|
| 164 |
+
return default_size
|
backend/api/public/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public API Module
|
| 3 |
+
|
| 4 |
+
Evaluation-as-a-Service (EaaS) API for external clients.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from backend.api.public.routes import router
|
| 8 |
+
|
| 9 |
+
__all__ = ["router"]
|
backend/api/public/auth.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public API Authentication
|
| 3 |
+
|
| 4 |
+
API Key authentication for the public Evaluation-as-a-Service API.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
import secrets
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Optional, Any
|
| 12 |
+
|
| 13 |
+
from fastapi import Depends, HTTPException, status
|
| 14 |
+
from fastapi.security import APIKeyHeader
|
| 15 |
+
from sqlalchemy import select
|
| 16 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 17 |
+
|
| 18 |
+
from backend.api.dependencies import get_db
|
| 19 |
+
from backend.db.models import APIKey as APIKeyModel
|
| 20 |
+
from backend.logging.logger import get_logger
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# Initialize logger
|
| 24 |
+
logger = get_logger("public_api_auth", component="api")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# =============================================================================
|
| 28 |
+
# API Key Header
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
API_KEY_HEADER = APIKeyHeader(
|
| 32 |
+
name="Authorization",
|
| 33 |
+
auto_error=False,
|
| 34 |
+
description="API Key in format: Bearer <API_KEY>"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# Helper Functions
|
| 40 |
+
# =============================================================================
|
| 41 |
+
|
| 42 |
+
def hash_api_key(key: str) -> str:
|
| 43 |
+
"""
|
| 44 |
+
Hash an API key using SHA-256.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
key: The raw API key to hash
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
The hexadecimal hash of the key
|
| 51 |
+
"""
|
| 52 |
+
return hashlib.sha256(key.encode()).hexdigest()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def generate_api_key() -> str:
|
| 56 |
+
"""
|
| 57 |
+
Generate a new random API key.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
A random 32-byte API key encoded as a hex string
|
| 61 |
+
"""
|
| 62 |
+
return secrets.token_hex(32)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def verify_api_key(
|
| 66 |
+
key: str,
|
| 67 |
+
db: AsyncSession
|
| 68 |
+
) -> Optional[APIKeyModel]:
|
| 69 |
+
"""
|
| 70 |
+
Verify an API key against the database.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
key: The raw API key to verify
|
| 74 |
+
db: Database session
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
The APIKeyModel if valid, None otherwise
|
| 78 |
+
"""
|
| 79 |
+
key_hash = hash_api_key(key)
|
| 80 |
+
|
| 81 |
+
result = await db.execute(
|
| 82 |
+
select(APIKeyModel).where(
|
| 83 |
+
APIKeyModel.key_hash == key_hash,
|
| 84 |
+
APIKeyModel.active == True
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
api_key = result.scalar_one_or_none()
|
| 88 |
+
|
| 89 |
+
if api_key:
|
| 90 |
+
# Update last used timestamp
|
| 91 |
+
api_key.last_used = datetime.utcnow()
|
| 92 |
+
await db.commit()
|
| 93 |
+
|
| 94 |
+
logger.info(
|
| 95 |
+
"API key verified",
|
| 96 |
+
metadata={
|
| 97 |
+
"api_key_id": str(api_key.id),
|
| 98 |
+
"owner": api_key.owner
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
return api_key
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
async def get_current_api_key(
|
| 106 |
+
api_key_header: Optional[str] = Depends(API_KEY_HEADER),
|
| 107 |
+
db: AsyncSession = Depends(get_db)
|
| 108 |
+
) -> Any:
|
| 109 |
+
"""
|
| 110 |
+
Dependency to get the current authenticated API key.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
api_key_header: The Authorization header value
|
| 114 |
+
db: Database session
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
The authenticated APIKeyModel
|
| 118 |
+
|
| 119 |
+
Raises:
|
| 120 |
+
HTTPException: If the API key is invalid or missing
|
| 121 |
+
"""
|
| 122 |
+
if not api_key_header:
|
| 123 |
+
logger.warning("Missing API key header")
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 126 |
+
detail={
|
| 127 |
+
"error": "authentication_failed",
|
| 128 |
+
"message": "API key is required. Include it in the Authorization header: Bearer <API_KEY>"
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Parse the header - expect "Bearer <key>"
|
| 133 |
+
parts = api_key_header.split(" ")
|
| 134 |
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
| 135 |
+
logger.warning("Invalid Authorization header format")
|
| 136 |
+
raise HTTPException(
|
| 137 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 138 |
+
detail={
|
| 139 |
+
"error": "authentication_failed",
|
| 140 |
+
"message": "Invalid Authorization header format. Use: Bearer <API_KEY>"
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
api_key = parts[1]
|
| 145 |
+
|
| 146 |
+
# Verify the key
|
| 147 |
+
verified_key = await verify_api_key(api_key, db)
|
| 148 |
+
|
| 149 |
+
if not verified_key:
|
| 150 |
+
logger.warning("Invalid API key attempted", metadata={"key_prefix": api_key[:8] + "..."})
|
| 151 |
+
raise HTTPException(
|
| 152 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 153 |
+
detail={
|
| 154 |
+
"error": "authentication_failed",
|
| 155 |
+
"message": "Invalid or inactive API key"
|
| 156 |
+
}
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return verified_key
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# =============================================================================
|
| 163 |
+
# API Key Management Functions
|
| 164 |
+
# =============================================================================
|
| 165 |
+
|
| 166 |
+
async def create_api_key(
|
| 167 |
+
owner: str,
|
| 168 |
+
rate_limit: int = 100,
|
| 169 |
+
evaluation_mode_restriction: Optional[str] = None,
|
| 170 |
+
mutation_enabled: bool = True,
|
| 171 |
+
monitoring_only: bool = False,
|
| 172 |
+
db: Optional[AsyncSession] = None
|
| 173 |
+
) -> tuple[str, APIKeyModel]:
|
| 174 |
+
"""
|
| 175 |
+
Create a new API key.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
owner: Owner identifier
|
| 179 |
+
rate_limit: Requests per minute limit
|
| 180 |
+
evaluation_mode_restriction: Optional evaluation mode restriction
|
| 181 |
+
mutation_enabled: Whether mutation is enabled
|
| 182 |
+
monitoring_only: Whether monitoring-only mode
|
| 183 |
+
db: Database session (optional, for testing)
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Tuple of (raw_api_key, APIKeyModel)
|
| 187 |
+
"""
|
| 188 |
+
# Generate the raw key (this is shown only once)
|
| 189 |
+
raw_key = generate_api_key()
|
| 190 |
+
key_hash = hash_api_key(raw_key)
|
| 191 |
+
|
| 192 |
+
# Create the model
|
| 193 |
+
api_key = APIKeyModel(
|
| 194 |
+
id=uuid.uuid4(),
|
| 195 |
+
key_hash=key_hash,
|
| 196 |
+
owner=owner,
|
| 197 |
+
rate_limit=rate_limit,
|
| 198 |
+
created_at=datetime.utcnow(),
|
| 199 |
+
active=True,
|
| 200 |
+
evaluation_mode_restriction=evaluation_mode_restriction,
|
| 201 |
+
mutation_enabled=mutation_enabled,
|
| 202 |
+
monitoring_only=monitoring_only
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
if db:
|
| 206 |
+
db.add(api_key)
|
| 207 |
+
await db.commit()
|
| 208 |
+
await db.refresh(api_key)
|
| 209 |
+
|
| 210 |
+
logger.info(
|
| 211 |
+
"API key created",
|
| 212 |
+
metadata={
|
| 213 |
+
"api_key_id": str(api_key.id),
|
| 214 |
+
"owner": owner,
|
| 215 |
+
"rate_limit": rate_limit
|
| 216 |
+
}
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
return raw_key, api_key
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def revoke_api_key(
|
| 223 |
+
api_key_id: uuid.UUID,
|
| 224 |
+
db: AsyncSession
|
| 225 |
+
) -> bool:
|
| 226 |
+
"""
|
| 227 |
+
Revoke an API key.
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
api_key_id: ID of the API key to revoke
|
| 231 |
+
db: Database session
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
True if revoked, False if not found
|
| 235 |
+
"""
|
| 236 |
+
result = await db.execute(
|
| 237 |
+
select(APIKeyModel).where(APIKeyModel.id == api_key_id)
|
| 238 |
+
)
|
| 239 |
+
api_key = result.scalar_one_or_none()
|
| 240 |
+
|
| 241 |
+
if not api_key:
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
api_key.active = False
|
| 245 |
+
await db.commit()
|
| 246 |
+
|
| 247 |
+
logger.info(
|
| 248 |
+
"API key revoked",
|
| 249 |
+
metadata={
|
| 250 |
+
"api_key_id": str(api_key_id),
|
| 251 |
+
"owner": api_key.owner
|
| 252 |
+
}
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
return True
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
async def get_api_key_info(
|
| 259 |
+
api_key_id: uuid.UUID,
|
| 260 |
+
db: AsyncSession
|
| 261 |
+
) -> Optional[APIKeyModel]:
|
| 262 |
+
"""
|
| 263 |
+
Get information about an API key (without exposing the hash).
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
api_key_id: ID of the API key
|
| 267 |
+
db: Database session
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
APIKeyModel if found
|
| 271 |
+
"""
|
| 272 |
+
result = await db.execute(
|
| 273 |
+
select(APIKeyModel).where(APIKeyModel.id == api_key_id)
|
| 274 |
+
)
|
| 275 |
+
return result.scalar_one_or_none()
|
backend/api/public/rate_limit.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public API Rate Limiting
|
| 3 |
+
|
| 4 |
+
Per-key rate limiting with rolling window for the public Evaluation-as-a-Service API.
|
| 5 |
+
Uses Redis for distributed rate limiting in production.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import Dict, Optional, Any
|
| 12 |
+
|
| 13 |
+
import redis.asyncio as redis
|
| 14 |
+
from fastapi import HTTPException, status
|
| 15 |
+
|
| 16 |
+
from backend.core.config import settings
|
| 17 |
+
from backend.logging.logger import get_logger
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Initialize logger
|
| 21 |
+
logger = get_logger("public_api_rate_limit", component="api")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _is_production() -> bool:
|
| 25 |
+
"""Check if running in production mode."""
|
| 26 |
+
return os.getenv("AEGISLM_ENV", "development").lower() == "production"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# =============================================================================
|
| 30 |
+
# Redis-Backed Rate Limiter
|
| 31 |
+
# =============================================================================
|
| 32 |
+
|
| 33 |
+
class RateLimiter:
|
| 34 |
+
"""
|
| 35 |
+
Redis-backed rate limiter using sliding window algorithm.
|
| 36 |
+
|
| 37 |
+
In production (AEGISLM_ENV=production):
|
| 38 |
+
- Redis is REQUIRED
|
| 39 |
+
- No fallback to in-memory allowed
|
| 40 |
+
- System fails fast if Redis unavailable
|
| 41 |
+
|
| 42 |
+
In development:
|
| 43 |
+
- Graceful fallback to in-memory if Redis unavailable
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self):
|
| 47 |
+
self._redis_client: Optional[redis.Redis] = None
|
| 48 |
+
self._use_redis = True
|
| 49 |
+
self._redis_tested = False
|
| 50 |
+
# In-memory fallback for development ONLY
|
| 51 |
+
self._request_history: Dict[uuid.UUID, list] = {}
|
| 52 |
+
|
| 53 |
+
async def _get_redis(self) -> Optional[redis.Redis]:
|
| 54 |
+
"""Get or create Redis connection."""
|
| 55 |
+
if self._redis_client is None:
|
| 56 |
+
is_prod = _is_production()
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
self._redis_client = redis.from_url(
|
| 60 |
+
settings.redis_url,
|
| 61 |
+
encoding="utf-8",
|
| 62 |
+
decode_responses=True
|
| 63 |
+
)
|
| 64 |
+
# Test connection
|
| 65 |
+
await self._redis_client.ping()
|
| 66 |
+
self._redis_tested = True
|
| 67 |
+
logger.info("Redis connection established for rate limiting")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
self._redis_tested = True
|
| 70 |
+
|
| 71 |
+
if is_prod:
|
| 72 |
+
# PRODUCTION: FAIL FAST - No fallback allowed
|
| 73 |
+
logger.critical(
|
| 74 |
+
f"PRODUCTION MODE: Redis unavailable for rate limiting: {e}. "
|
| 75 |
+
"Production requires Redis. Refusing to start without Redis."
|
| 76 |
+
)
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
f"CRITICAL: Redis is required in production but unavailable: {e}. "
|
| 79 |
+
"Cannot start without Redis for rate limiting."
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
# DEVELOPMENT: Allow fallback
|
| 83 |
+
logger.warning(
|
| 84 |
+
f"Redis unavailable for rate limiting: {e}. "
|
| 85 |
+
"Using in-memory fallback (development mode only)."
|
| 86 |
+
)
|
| 87 |
+
self._use_redis = False
|
| 88 |
+
self._redis_client = None
|
| 89 |
+
|
| 90 |
+
return self._redis_client
|
| 91 |
+
|
| 92 |
+
async def check_rate_limit(
|
| 93 |
+
self,
|
| 94 |
+
api_key_id: uuid.UUID,
|
| 95 |
+
rate_limit: int,
|
| 96 |
+
window_seconds: int = 60
|
| 97 |
+
) -> tuple[bool, Optional[int]]:
|
| 98 |
+
"""
|
| 99 |
+
Check if the request is within rate limit.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
api_key_id: The API key ID
|
| 103 |
+
rate_limit: Maximum requests allowed in the window
|
| 104 |
+
window_seconds: Time window in seconds (default: 60 for per-minute)
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Tuple of (is_allowed, retry_after_seconds)
|
| 108 |
+
|
| 109 |
+
Raises:
|
| 110 |
+
RuntimeError: In production if Redis unavailable
|
| 111 |
+
"""
|
| 112 |
+
redis_client = await self._get_redis()
|
| 113 |
+
|
| 114 |
+
if redis_client and self._use_redis:
|
| 115 |
+
return await self._check_redis_rate_limit(
|
| 116 |
+
redis_client, api_key_id, rate_limit, window_seconds
|
| 117 |
+
)
|
| 118 |
+
else:
|
| 119 |
+
# Development fallback only
|
| 120 |
+
return self._check_memory_rate_limit(
|
| 121 |
+
api_key_id, rate_limit, window_seconds
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
async def _check_redis_rate_limit(
|
| 125 |
+
self,
|
| 126 |
+
redis_client: redis.Redis,
|
| 127 |
+
api_key_id: uuid.UUID,
|
| 128 |
+
rate_limit: int,
|
| 129 |
+
window_seconds: int
|
| 130 |
+
) -> tuple[bool, Optional[int]]:
|
| 131 |
+
"""Check rate limit using Redis."""
|
| 132 |
+
key = f"rate_limit:{api_key_id}"
|
| 133 |
+
now = time.time()
|
| 134 |
+
window_start = now - window_seconds
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
# Use Redis sorted set for sliding window
|
| 138 |
+
pipe = redis_client.pipeline()
|
| 139 |
+
|
| 140 |
+
# Remove old entries
|
| 141 |
+
pipe.zremrangebyscore(key, 0, window_start)
|
| 142 |
+
|
| 143 |
+
# Count current requests
|
| 144 |
+
pipe.zcard(key)
|
| 145 |
+
|
| 146 |
+
# Get oldest entry for retry calculation
|
| 147 |
+
pipe.zrange(key, 0, 0, withscores=True)
|
| 148 |
+
|
| 149 |
+
results = await pipe.execute()
|
| 150 |
+
|
| 151 |
+
current_count = results[1]
|
| 152 |
+
|
| 153 |
+
if current_count >= rate_limit:
|
| 154 |
+
# Calculate retry_after
|
| 155 |
+
oldest = results[2]
|
| 156 |
+
if oldest:
|
| 157 |
+
retry_after = int(oldest[1] + window_seconds - now) + 1
|
| 158 |
+
else:
|
| 159 |
+
retry_after = window_seconds
|
| 160 |
+
return False, retry_after
|
| 161 |
+
|
| 162 |
+
# Add new request
|
| 163 |
+
await redis_client.zadd(key, {str(now): now})
|
| 164 |
+
await redis_client.expire(key, window_seconds + 1)
|
| 165 |
+
|
| 166 |
+
return True, None
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Redis rate limit error: {e}")
|
| 170 |
+
|
| 171 |
+
# In production, this should have been caught at startup
|
| 172 |
+
# But double-check here
|
| 173 |
+
if _is_production():
|
| 174 |
+
raise RuntimeError(
|
| 175 |
+
f"CRITICAL: Redis rate limiting failed in production: {e}"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Fallback to memory on error in dev
|
| 179 |
+
return self._check_memory_rate_limit(
|
| 180 |
+
api_key_id, rate_limit, window_seconds
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
def _check_memory_rate_limit(
|
| 184 |
+
self,
|
| 185 |
+
api_key_id: uuid.UUID,
|
| 186 |
+
rate_limit: int,
|
| 187 |
+
window_seconds: int
|
| 188 |
+
) -> tuple[bool, Optional[int]]:
|
| 189 |
+
"""
|
| 190 |
+
Check rate limit using in-memory storage.
|
| 191 |
+
|
| 192 |
+
WARNING: This is ONLY for development mode.
|
| 193 |
+
Production must use Redis.
|
| 194 |
+
"""
|
| 195 |
+
if _is_production():
|
| 196 |
+
# This should never happen - we should have failed at startup
|
| 197 |
+
raise RuntimeError(
|
| 198 |
+
"CRITICAL: In-memory rate limiting not allowed in production. "
|
| 199 |
+
"Use Redis for rate limiting."
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
if api_key_id not in self._request_history:
|
| 203 |
+
self._request_history[api_key_id] = []
|
| 204 |
+
|
| 205 |
+
# Clean old requests
|
| 206 |
+
cutoff = time.time() - window_seconds
|
| 207 |
+
self._request_history[api_key_id] = [
|
| 208 |
+
ts for ts in self._request_history[api_key_id]
|
| 209 |
+
if ts > cutoff
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
current_count = len(self._request_history[api_key_id])
|
| 213 |
+
|
| 214 |
+
if current_count >= rate_limit:
|
| 215 |
+
oldest = self._request_history[api_key_id][0] if self._request_history[api_key_id] else 0
|
| 216 |
+
retry_after = int(oldest + window_seconds - time.time()) + 1
|
| 217 |
+
return False, retry_after
|
| 218 |
+
|
| 219 |
+
# Add new request
|
| 220 |
+
self._request_history[api_key_id].append(time.time())
|
| 221 |
+
return True, None
|
| 222 |
+
|
| 223 |
+
async def get_remaining_requests(
|
| 224 |
+
self,
|
| 225 |
+
api_key_id: uuid.UUID,
|
| 226 |
+
rate_limit: int,
|
| 227 |
+
window_seconds: int = 60
|
| 228 |
+
) -> int:
|
| 229 |
+
"""Get remaining requests in current window."""
|
| 230 |
+
redis_client = await self._get_redis()
|
| 231 |
+
|
| 232 |
+
if redis_client and self._use_redis:
|
| 233 |
+
key = f"rate_limit:{api_key_id}"
|
| 234 |
+
now = time.time()
|
| 235 |
+
window_start = now - window_seconds
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
await redis_client.zremrangebyscore(key, 0, window_start)
|
| 239 |
+
current_count = await redis_client.zcard(key)
|
| 240 |
+
return max(0, rate_limit - current_count)
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
|
| 244 |
+
# Fallback
|
| 245 |
+
if api_key_id not in self._request_history:
|
| 246 |
+
return rate_limit
|
| 247 |
+
|
| 248 |
+
cutoff = time.time() - window_seconds
|
| 249 |
+
self._request_history[api_key_id] = [
|
| 250 |
+
ts for ts in self._request_history[api_key_id]
|
| 251 |
+
if ts > cutoff
|
| 252 |
+
]
|
| 253 |
+
return max(0, rate_limit - len(self._request_history[api_key_id]))
|
| 254 |
+
|
| 255 |
+
async def reset(self, api_key_id: uuid.UUID) -> None:
|
| 256 |
+
"""Reset rate limit for an API key."""
|
| 257 |
+
redis_client = await self._get_redis()
|
| 258 |
+
|
| 259 |
+
if redis_client and self._use_redis:
|
| 260 |
+
try:
|
| 261 |
+
key = f"rate_limit:{api_key_id}"
|
| 262 |
+
await redis_client.delete(key)
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(f"Redis reset error: {e}")
|
| 265 |
+
|
| 266 |
+
# Also reset memory
|
| 267 |
+
if api_key_id in self._request_history:
|
| 268 |
+
del self._request_history[api_key_id]
|
| 269 |
+
|
| 270 |
+
def is_using_redis(self) -> bool:
|
| 271 |
+
"""Check if currently using Redis."""
|
| 272 |
+
return self._use_redis and self._redis_client is not None
|
| 273 |
+
|
| 274 |
+
def is_production_mode(self) -> bool:
|
| 275 |
+
"""Check if running in production mode."""
|
| 276 |
+
return _is_production()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# Global rate limiter instance
|
| 280 |
+
rate_limiter = RateLimiter()
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# =============================================================================
|
| 284 |
+
# FastAPI Dependency
|
| 285 |
+
# =============================================================================
|
| 286 |
+
|
| 287 |
+
async def check_rate_limit(api_key: Any) -> None:
|
| 288 |
+
"""
|
| 289 |
+
FastAPI dependency to check rate limit for the authenticated API key.
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
api_key: The authenticated API key model
|
| 293 |
+
|
| 294 |
+
Raises:
|
| 295 |
+
HTTPException: If rate limit is exceeded
|
| 296 |
+
RuntimeError: In production if Redis unavailable
|
| 297 |
+
"""
|
| 298 |
+
is_allowed, retry_after = await rate_limiter.check_rate_limit(
|
| 299 |
+
api_key_id=api_key.id,
|
| 300 |
+
rate_limit=api_key.rate_limit,
|
| 301 |
+
window_seconds=60 # 1 minute window
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
if not is_allowed:
|
| 305 |
+
remaining = await rate_limiter.get_remaining_requests(
|
| 306 |
+
api_key_id=api_key.id,
|
| 307 |
+
rate_limit=api_key.rate_limit,
|
| 308 |
+
window_seconds=60
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
raise HTTPException(
|
| 312 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 313 |
+
detail={
|
| 314 |
+
"error": "rate_limit_exceeded",
|
| 315 |
+
"message": f"Rate limit exceeded. Maximum {api_key.rate_limit} requests per minute allowed.",
|
| 316 |
+
"retry_after_seconds": retry_after
|
| 317 |
+
},
|
| 318 |
+
headers={
|
| 319 |
+
"Retry-After": str(retry_after),
|
| 320 |
+
"X-RateLimit-Limit": str(api_key.rate_limit),
|
| 321 |
+
"X-RateLimit-Remaining": str(remaining),
|
| 322 |
+
"X-RateLimit-Reset": str(int(time.time()) + retry_after)
|
| 323 |
+
}
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# =============================================================================
|
| 328 |
+
# Rate Limit Management
|
| 329 |
+
# =============================================================================
|
| 330 |
+
|
| 331 |
+
async def get_rate_limit_status(api_key: Any) -> dict:
|
| 332 |
+
"""
|
| 333 |
+
Get the current rate limit status for an API key.
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
api_key: The API key model
|
| 337 |
+
|
| 338 |
+
Returns:
|
| 339 |
+
Dictionary with rate limit status
|
| 340 |
+
"""
|
| 341 |
+
remaining = await rate_limiter.get_remaining_requests(
|
| 342 |
+
api_key_id=api_key.id,
|
| 343 |
+
rate_limit=api_key.rate_limit,
|
| 344 |
+
window_seconds=60
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
return {
|
| 348 |
+
"limit": api_key.rate_limit,
|
| 349 |
+
"remaining": remaining,
|
| 350 |
+
"reset_in_seconds": 60,
|
| 351 |
+
"using_redis": rate_limiter.is_using_redis(),
|
| 352 |
+
"production_mode": rate_limiter.is_production_mode()
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
async def reset_api_key_rate_limit(api_key_id: uuid.UUID) -> None:
|
| 357 |
+
"""
|
| 358 |
+
Reset the rate limit for an API key (admin function).
|
| 359 |
+
|
| 360 |
+
Args:
|
| 361 |
+
api_key_id: The API key ID to reset
|
| 362 |
+
"""
|
| 363 |
+
await rate_limiter.reset(api_key_id)
|
| 364 |
+
|
| 365 |
+
logger.info(
|
| 366 |
+
"Rate limit reset",
|
| 367 |
+
metadata={"api_key_id": str(api_key_id)}
|
| 368 |
+
)
|
backend/api/public/routes.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public API Routes
|
| 3 |
+
|
| 4 |
+
FastAPI routes for the public Evaluation-as-a-Service API.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import List
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
from sqlalchemy import select
|
| 13 |
+
|
| 14 |
+
from backend.api.dependencies import get_db
|
| 15 |
+
from backend.api.public.auth import (
|
| 16 |
+
create_api_key,
|
| 17 |
+
get_current_api_key,
|
| 18 |
+
revoke_api_key,
|
| 19 |
+
)
|
| 20 |
+
from backend.api.public.schemas import APIKeyWithSecret
|
| 21 |
+
from backend.api.public.rate_limit import check_rate_limit, get_rate_limit_status
|
| 22 |
+
from backend.api.public.schemas import (
|
| 23 |
+
APIKeyCreate,
|
| 24 |
+
APIKeyResponse,
|
| 25 |
+
BatchEvaluateRequest,
|
| 26 |
+
BatchEvaluateResponse,
|
| 27 |
+
EvaluateRequest,
|
| 28 |
+
EvaluateResponse,
|
| 29 |
+
ModelStatusResponse,
|
| 30 |
+
)
|
| 31 |
+
from backend.api.public.service import evaluate_batch, evaluate_prompt, get_model_status
|
| 32 |
+
from backend.db.models import APIKey as APIKeyModel
|
| 33 |
+
from backend.logging.logger import get_logger
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Initialize logger
|
| 37 |
+
logger = get_logger("public_api_routes", component="api")
|
| 38 |
+
|
| 39 |
+
# Create router
|
| 40 |
+
router = APIRouter(prefix="/api/v1", tags=["public"])
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# =============================================================================
|
| 44 |
+
# Evaluation Endpoints
|
| 45 |
+
# =============================================================================
|
| 46 |
+
|
| 47 |
+
@router.post(
|
| 48 |
+
"/evaluate",
|
| 49 |
+
response_model=EvaluateResponse,
|
| 50 |
+
summary="Evaluate a single prompt",
|
| 51 |
+
description="Evaluate a single prompt and return hallucination, toxicity, bias, and confidence scores."
|
| 52 |
+
)
|
| 53 |
+
async def evaluate_single_prompt(
|
| 54 |
+
request: EvaluateRequest,
|
| 55 |
+
api_key = Depends(get_current_api_key),
|
| 56 |
+
_: None = Depends(check_rate_limit),
|
| 57 |
+
):
|
| 58 |
+
"""
|
| 59 |
+
Evaluate a single prompt.
|
| 60 |
+
|
| 61 |
+
Returns scores for hallucination, toxicity, bias, and confidence,
|
| 62 |
+
along with a composite robustness score and risk level classification.
|
| 63 |
+
"""
|
| 64 |
+
# Check for evaluation mode restrictions on the API key
|
| 65 |
+
if api_key.evaluation_mode_restriction:
|
| 66 |
+
if request.evaluation_mode.value != api_key.evaluation_mode_restriction:
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 69 |
+
detail={
|
| 70 |
+
"error": "evaluation_mode_restricted",
|
| 71 |
+
"message": f"This API key is restricted to {api_key.evaluation_mode_restriction} mode only."
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Check for monitoring-only mode
|
| 76 |
+
if api_key.monitoring_only:
|
| 77 |
+
request.monitoring_mode = True
|
| 78 |
+
|
| 79 |
+
# Log API request received
|
| 80 |
+
logger.info(
|
| 81 |
+
"API_REQUEST_RECEIVED",
|
| 82 |
+
metadata={
|
| 83 |
+
"api_key_id": str(api_key.id),
|
| 84 |
+
"owner": api_key.owner,
|
| 85 |
+
"endpoint": "/evaluate",
|
| 86 |
+
"model_name": request.model_name
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
result = await evaluate_prompt(
|
| 92 |
+
model_name=request.model_name,
|
| 93 |
+
prompt=request.prompt,
|
| 94 |
+
evaluation_mode=request.evaluation_mode,
|
| 95 |
+
monitoring_mode=request.monitoring_mode,
|
| 96 |
+
temperature=request.temperature,
|
| 97 |
+
max_tokens=request.max_tokens
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Log API request completed
|
| 101 |
+
logger.info(
|
| 102 |
+
"API_REQUEST_COMPLETED",
|
| 103 |
+
metadata={
|
| 104 |
+
"api_key_id": str(api_key.id),
|
| 105 |
+
"owner": api_key.owner,
|
| 106 |
+
"endpoint": "/evaluate",
|
| 107 |
+
"model_name": request.model_name,
|
| 108 |
+
"robustness": result.robustness,
|
| 109 |
+
"risk_level": result.risk_level.value,
|
| 110 |
+
"latency_ms": result.latency_ms
|
| 111 |
+
}
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return result
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(
|
| 118 |
+
"API_REQUEST_FAILED",
|
| 119 |
+
metadata={
|
| 120 |
+
"api_key_id": str(api_key.id),
|
| 121 |
+
"owner": api_key.owner,
|
| 122 |
+
"endpoint": "/evaluate",
|
| 123 |
+
"error": str(e)
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
raise
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post(
|
| 130 |
+
"/evaluate/batch",
|
| 131 |
+
response_model=BatchEvaluateResponse,
|
| 132 |
+
summary="Evaluate multiple prompts",
|
| 133 |
+
description="Evaluate multiple prompts in batch and return scores for each."
|
| 134 |
+
)
|
| 135 |
+
async def evaluate_batch_prompts(
|
| 136 |
+
request: BatchEvaluateRequest,
|
| 137 |
+
api_key = Depends(get_current_api_key),
|
| 138 |
+
_: None = Depends(check_rate_limit),
|
| 139 |
+
):
|
| 140 |
+
"""
|
| 141 |
+
Evaluate multiple prompts in batch.
|
| 142 |
+
|
| 143 |
+
Accepts up to 100 prompts per request and returns evaluation
|
| 144 |
+
results for each prompt.
|
| 145 |
+
"""
|
| 146 |
+
# Check for evaluation mode restrictions
|
| 147 |
+
if api_key.evaluation_mode_restriction:
|
| 148 |
+
if request.evaluation_mode.value != api_key.evaluation_mode_restriction:
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 151 |
+
detail={
|
| 152 |
+
"error": "evaluation_mode_restricted",
|
| 153 |
+
"message": f"This API key is restricted to {api_key.evaluation_mode_restriction} mode only."
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Check for monitoring-only mode
|
| 158 |
+
if api_key.monitoring_only:
|
| 159 |
+
request.monitoring_mode = True
|
| 160 |
+
|
| 161 |
+
# Log API request received
|
| 162 |
+
logger.info(
|
| 163 |
+
"API_REQUEST_RECEIVED",
|
| 164 |
+
metadata={
|
| 165 |
+
"api_key_id": str(api_key.id),
|
| 166 |
+
"owner": api_key.owner,
|
| 167 |
+
"endpoint": "/evaluate/batch",
|
| 168 |
+
"model_name": request.model_name,
|
| 169 |
+
"prompt_count": len(request.prompts)
|
| 170 |
+
}
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
result = await evaluate_batch(
|
| 175 |
+
model_name=request.model_name,
|
| 176 |
+
prompts=request.prompts,
|
| 177 |
+
evaluation_mode=request.evaluation_mode,
|
| 178 |
+
monitoring_mode=request.monitoring_mode,
|
| 179 |
+
temperature=request.temperature,
|
| 180 |
+
max_tokens=request.max_tokens
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Log API request completed
|
| 184 |
+
logger.info(
|
| 185 |
+
"API_REQUEST_COMPLETED",
|
| 186 |
+
metadata={
|
| 187 |
+
"api_key_id": str(api_key.id),
|
| 188 |
+
"owner": api_key.owner,
|
| 189 |
+
"endpoint": "/evaluate/batch",
|
| 190 |
+
"model_name": request.model_name,
|
| 191 |
+
"prompt_count": len(request.prompts),
|
| 192 |
+
"processing_time_ms": result.processing_time_ms
|
| 193 |
+
}
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
return result
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(
|
| 200 |
+
"API_REQUEST_FAILED",
|
| 201 |
+
metadata={
|
| 202 |
+
"api_key_id": str(api_key.id),
|
| 203 |
+
"owner": api_key.owner,
|
| 204 |
+
"endpoint": "/evaluate/batch",
|
| 205 |
+
"error": str(e)
|
| 206 |
+
}
|
| 207 |
+
)
|
| 208 |
+
raise
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# =============================================================================
|
| 212 |
+
# Model Status Endpoints
|
| 213 |
+
# =============================================================================
|
| 214 |
+
|
| 215 |
+
@router.get(
|
| 216 |
+
"/model/status",
|
| 217 |
+
response_model=ModelStatusResponse,
|
| 218 |
+
summary="Get model status",
|
| 219 |
+
description="Get the current status and baseline metrics for a model."
|
| 220 |
+
)
|
| 221 |
+
async def get_model_health(
|
| 222 |
+
model_name: str = Query(..., description="Model name to check status for"),
|
| 223 |
+
api_key = Depends(get_current_api_key),
|
| 224 |
+
):
|
| 225 |
+
"""
|
| 226 |
+
Get model status information.
|
| 227 |
+
|
| 228 |
+
Returns the model version, robustness baseline, and active alerts.
|
| 229 |
+
"""
|
| 230 |
+
# Log API request received
|
| 231 |
+
logger.info(
|
| 232 |
+
"API_REQUEST_RECEIVED",
|
| 233 |
+
metadata={
|
| 234 |
+
"api_key_id": str(api_key.id),
|
| 235 |
+
"owner": api_key.owner,
|
| 236 |
+
"endpoint": "/model/status",
|
| 237 |
+
"model_name": model_name
|
| 238 |
+
}
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
try:
|
| 242 |
+
status_info = await get_model_status(model_name)
|
| 243 |
+
|
| 244 |
+
return ModelStatusResponse(
|
| 245 |
+
model_name=status_info["model_name"],
|
| 246 |
+
model_version=status_info["model_version"],
|
| 247 |
+
robustness_baseline=status_info["robustness_baseline"],
|
| 248 |
+
active_alerts=status_info["active_alerts"],
|
| 249 |
+
status=status_info["status"],
|
| 250 |
+
last_updated=status_info["last_updated"]
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.error(
|
| 255 |
+
"API_REQUEST_FAILED",
|
| 256 |
+
metadata={
|
| 257 |
+
"api_key_id": str(api_key.id),
|
| 258 |
+
"owner": api_key.owner,
|
| 259 |
+
"endpoint": "/model/status",
|
| 260 |
+
"error": str(e)
|
| 261 |
+
}
|
| 262 |
+
)
|
| 263 |
+
raise
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# =============================================================================
|
| 267 |
+
# Rate Limit Status Endpoint
|
| 268 |
+
# =============================================================================
|
| 269 |
+
|
| 270 |
+
@router.get(
|
| 271 |
+
"/rate-limit/status",
|
| 272 |
+
summary="Get rate limit status",
|
| 273 |
+
description="Get the current rate limit status for the API key."
|
| 274 |
+
)
|
| 275 |
+
async def get_rate_limit(
|
| 276 |
+
api_key = Depends(get_current_api_key),
|
| 277 |
+
):
|
| 278 |
+
"""
|
| 279 |
+
Get rate limit status.
|
| 280 |
+
|
| 281 |
+
Returns the current rate limit, remaining requests, and reset time.
|
| 282 |
+
"""
|
| 283 |
+
status = await get_rate_limit_status(api_key)
|
| 284 |
+
|
| 285 |
+
return {
|
| 286 |
+
"rate_limit": status["limit"],
|
| 287 |
+
"remaining": status["remaining"],
|
| 288 |
+
"reset_in_seconds": status["reset_in_seconds"]
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# =============================================================================
|
| 293 |
+
# API Key Management Endpoints (Admin)
|
| 294 |
+
# =============================================================================
|
| 295 |
+
|
| 296 |
+
@router.post(
|
| 297 |
+
"/keys",
|
| 298 |
+
response_model=APIKeyWithSecret,
|
| 299 |
+
status_code=status.HTTP_201_CREATED,
|
| 300 |
+
summary="Create a new API key",
|
| 301 |
+
description="Create a new API key for accessing the public API."
|
| 302 |
+
)
|
| 303 |
+
async def create_api_key_endpoint(
|
| 304 |
+
request: APIKeyCreate,
|
| 305 |
+
db: AsyncSession = Depends(get_db),
|
| 306 |
+
):
|
| 307 |
+
"""
|
| 308 |
+
Create a new API key.
|
| 309 |
+
|
| 310 |
+
The actual API key is returned only once upon creation.
|
| 311 |
+
Make sure to store it securely.
|
| 312 |
+
"""
|
| 313 |
+
raw_key, api_key = await create_api_key(
|
| 314 |
+
owner=request.owner,
|
| 315 |
+
rate_limit=request.rate_limit,
|
| 316 |
+
evaluation_mode_restriction=request.evaluation_mode_restriction.value if request.evaluation_mode_restriction else None,
|
| 317 |
+
mutation_enabled=request.mutation_enabled,
|
| 318 |
+
monitoring_only=request.monitoring_only,
|
| 319 |
+
db=db
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
return APIKeyWithSecret(
|
| 323 |
+
id=api_key.id,
|
| 324 |
+
key=raw_key,
|
| 325 |
+
owner=api_key.owner,
|
| 326 |
+
rate_limit=api_key.rate_limit,
|
| 327 |
+
created_at=api_key.created_at
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@router.get(
|
| 332 |
+
"/keys",
|
| 333 |
+
response_model=List[APIKeyResponse],
|
| 334 |
+
summary="List API keys",
|
| 335 |
+
description="List all API keys for the authenticated user."
|
| 336 |
+
)
|
| 337 |
+
async def list_api_keys(
|
| 338 |
+
limit: int = Query(default=10, ge=1, le=100),
|
| 339 |
+
offset: int = Query(default=0, ge=0),
|
| 340 |
+
db: AsyncSession = Depends(get_db),
|
| 341 |
+
api_key = Depends(get_current_api_key),
|
| 342 |
+
):
|
| 343 |
+
"""
|
| 344 |
+
List API keys.
|
| 345 |
+
|
| 346 |
+
Returns a paginated list of API keys.
|
| 347 |
+
"""
|
| 348 |
+
# Query API keys for the current user
|
| 349 |
+
query = (
|
| 350 |
+
select(APIKeyModel)
|
| 351 |
+
.where(APIKeyModel.owner == api_key.owner)
|
| 352 |
+
.offset(offset)
|
| 353 |
+
.limit(limit)
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
result = await db.execute(query)
|
| 357 |
+
keys = result.scalars().all()
|
| 358 |
+
|
| 359 |
+
return [
|
| 360 |
+
APIKeyResponse(
|
| 361 |
+
id=key.id,
|
| 362 |
+
owner=key.owner,
|
| 363 |
+
rate_limit=key.rate_limit,
|
| 364 |
+
created_at=key.created_at,
|
| 365 |
+
active=key.active,
|
| 366 |
+
last_used=key.last_used,
|
| 367 |
+
evaluation_mode_restriction=key.evaluation_mode_restriction,
|
| 368 |
+
mutation_enabled=key.mutation_enabled,
|
| 369 |
+
monitoring_only=key.monitoring_only
|
| 370 |
+
)
|
| 371 |
+
for key in keys
|
| 372 |
+
]
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@router.delete(
|
| 376 |
+
"/keys/{key_id}",
|
| 377 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 378 |
+
summary="Revoke an API key",
|
| 379 |
+
description="Revoke an existing API key."
|
| 380 |
+
)
|
| 381 |
+
async def revoke_api_key_endpoint(
|
| 382 |
+
key_id: uuid.UUID,
|
| 383 |
+
db: AsyncSession = Depends(get_db),
|
| 384 |
+
api_key = Depends(get_current_api_key),
|
| 385 |
+
):
|
| 386 |
+
"""
|
| 387 |
+
Revoke an API key.
|
| 388 |
+
|
| 389 |
+
Once revoked, the key can no longer be used for API requests.
|
| 390 |
+
"""
|
| 391 |
+
# Verify the key belongs to the current user
|
| 392 |
+
result = await db.execute(
|
| 393 |
+
select(APIKeyModel).where(APIKeyModel.id == key_id)
|
| 394 |
+
)
|
| 395 |
+
key_to_revoke = result.scalar_one_or_none()
|
| 396 |
+
|
| 397 |
+
if not key_to_revoke:
|
| 398 |
+
raise HTTPException(
|
| 399 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 400 |
+
detail={"error": "not_found", "message": "API key not found"}
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
if key_to_revoke.owner != api_key.owner:
|
| 404 |
+
raise HTTPException(
|
| 405 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 406 |
+
detail={"error": "forbidden", "message": "Cannot revoke another user's API key"}
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
await revoke_api_key(key_id, db)
|
backend/api/public/schemas.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public API Schemas
|
| 3 |
+
|
| 4 |
+
Pydantic models for the public Evaluation-as-a-Service API.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import List, Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field, field_validator
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class RiskLevel(str, Enum):
|
| 16 |
+
"""Risk level classification based on robustness score."""
|
| 17 |
+
LOW = "LOW"
|
| 18 |
+
MODERATE = "MODERATE"
|
| 19 |
+
HIGH = "HIGH"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class EvaluationMode(str, Enum):
|
| 23 |
+
"""Evaluation mode for the public API."""
|
| 24 |
+
LIGHTWEIGHT = "lightweight"
|
| 25 |
+
FULL = "full"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# =============================================================================
|
| 29 |
+
# Request Models
|
| 30 |
+
# =============================================================================
|
| 31 |
+
|
| 32 |
+
class EvaluateRequest(BaseModel):
|
| 33 |
+
"""Request model for single prompt evaluation."""
|
| 34 |
+
|
| 35 |
+
model_name: str = Field(
|
| 36 |
+
description="Model to evaluate",
|
| 37 |
+
examples=["meta-llama/Llama-2-7b-hf"]
|
| 38 |
+
)
|
| 39 |
+
prompt: str = Field(
|
| 40 |
+
description="Prompt to evaluate",
|
| 41 |
+
min_length=1,
|
| 42 |
+
max_length=8192,
|
| 43 |
+
examples=["Explain nuclear fusion."]
|
| 44 |
+
)
|
| 45 |
+
monitoring_mode: bool = Field(
|
| 46 |
+
default=False,
|
| 47 |
+
description="Use lightweight monitoring mode for faster evaluation"
|
| 48 |
+
)
|
| 49 |
+
evaluation_mode: EvaluationMode = Field(
|
| 50 |
+
default=EvaluationMode.LIGHTWEIGHT,
|
| 51 |
+
description="Evaluation mode: lightweight or full"
|
| 52 |
+
)
|
| 53 |
+
temperature: Optional[float] = Field(
|
| 54 |
+
default=None,
|
| 55 |
+
ge=0.0,
|
| 56 |
+
le=2.0,
|
| 57 |
+
description="Generation temperature (uses default if not specified)"
|
| 58 |
+
)
|
| 59 |
+
max_tokens: Optional[int] = Field(
|
| 60 |
+
default=None,
|
| 61 |
+
ge=1,
|
| 62 |
+
le=4096,
|
| 63 |
+
description="Maximum tokens to generate"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
@field_validator("prompt")
|
| 67 |
+
@classmethod
|
| 68 |
+
def validate_prompt(cls, v: str) -> str:
|
| 69 |
+
"""Validate prompt is not empty or whitespace only."""
|
| 70 |
+
if not v.strip():
|
| 71 |
+
raise ValueError("Prompt cannot be empty or whitespace only")
|
| 72 |
+
return v
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class BatchEvaluateRequest(BaseModel):
|
| 76 |
+
"""Request model for batch prompt evaluation."""
|
| 77 |
+
|
| 78 |
+
model_name: str = Field(
|
| 79 |
+
description="Model to evaluate",
|
| 80 |
+
examples=["meta-llama/Llama-2-7b-hf"]
|
| 81 |
+
)
|
| 82 |
+
prompts: List[str] = Field(
|
| 83 |
+
description="List of prompts to evaluate",
|
| 84 |
+
min_length=1,
|
| 85 |
+
max_length=100,
|
| 86 |
+
examples=[["Explain nuclear fusion.", "What is photosynthesis?"]]
|
| 87 |
+
)
|
| 88 |
+
monitoring_mode: bool = Field(
|
| 89 |
+
default=False,
|
| 90 |
+
description="Use lightweight monitoring mode"
|
| 91 |
+
)
|
| 92 |
+
evaluation_mode: EvaluationMode = Field(
|
| 93 |
+
default=EvaluationMode.LIGHTWEIGHT,
|
| 94 |
+
description="Evaluation mode: lightweight or full"
|
| 95 |
+
)
|
| 96 |
+
temperature: Optional[float] = Field(
|
| 97 |
+
default=None,
|
| 98 |
+
ge=0.0,
|
| 99 |
+
le=2.0,
|
| 100 |
+
description="Generation temperature"
|
| 101 |
+
)
|
| 102 |
+
max_tokens: Optional[int] = Field(
|
| 103 |
+
default=None,
|
| 104 |
+
ge=1,
|
| 105 |
+
le=4096,
|
| 106 |
+
description="Maximum tokens to generate"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
@field_validator("prompts")
|
| 110 |
+
@classmethod
|
| 111 |
+
def validate_prompts(cls, v: List[str]) -> List[str]:
|
| 112 |
+
"""Validate all prompts are non-empty."""
|
| 113 |
+
for i, prompt in enumerate(v):
|
| 114 |
+
if not prompt.strip():
|
| 115 |
+
raise ValueError(f"Prompt at index {i} is empty or whitespace only")
|
| 116 |
+
if len(prompt) > 8192:
|
| 117 |
+
raise ValueError(f"Prompt at index {i} exceeds maximum length of 8192")
|
| 118 |
+
return v
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class ModelStatusRequest(BaseModel):
|
| 122 |
+
"""Request model for model status check."""
|
| 123 |
+
|
| 124 |
+
model_name: str = Field(
|
| 125 |
+
description="Model name to check status for",
|
| 126 |
+
examples=["meta-llama/Llama-2-7b-hf"]
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# =============================================================================
|
| 131 |
+
# Response Models
|
| 132 |
+
# =============================================================================
|
| 133 |
+
|
| 134 |
+
class EvaluateResponse(BaseModel):
|
| 135 |
+
"""Response model for single prompt evaluation."""
|
| 136 |
+
|
| 137 |
+
hallucination: float = Field(
|
| 138 |
+
description="Hallucination score (0 = no hallucination, 1 = full hallucination)",
|
| 139 |
+
ge=0.0,
|
| 140 |
+
le=1.0
|
| 141 |
+
)
|
| 142 |
+
toxicity: float = Field(
|
| 143 |
+
description="Toxicity score (0 = non-toxic, 1 = toxic)",
|
| 144 |
+
ge=0.0,
|
| 145 |
+
le=1.0
|
| 146 |
+
)
|
| 147 |
+
bias: float = Field(
|
| 148 |
+
description="Bias score (0 = unbiased, 1 = biased)",
|
| 149 |
+
ge=0.0,
|
| 150 |
+
le=1.0
|
| 151 |
+
)
|
| 152 |
+
confidence: float = Field(
|
| 153 |
+
description="Model confidence score (0 = low, 1 = high)",
|
| 154 |
+
ge=0.0,
|
| 155 |
+
le=1.0
|
| 156 |
+
)
|
| 157 |
+
robustness: float = Field(
|
| 158 |
+
description="Composite robustness score (0-1)",
|
| 159 |
+
ge=0.0,
|
| 160 |
+
le=1.0
|
| 161 |
+
)
|
| 162 |
+
risk_level: RiskLevel = Field(
|
| 163 |
+
description="Risk level based on robustness score"
|
| 164 |
+
)
|
| 165 |
+
latency_ms: float = Field(
|
| 166 |
+
description="Processing time in milliseconds"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class BatchEvaluateResponse(BaseModel):
|
| 171 |
+
"""Response model for batch prompt evaluation."""
|
| 172 |
+
|
| 173 |
+
results: List[EvaluateResponse] = Field(
|
| 174 |
+
description="Evaluation results for each prompt"
|
| 175 |
+
)
|
| 176 |
+
total_prompts: int = Field(
|
| 177 |
+
description="Total number of prompts processed"
|
| 178 |
+
)
|
| 179 |
+
processing_time_ms: float = Field(
|
| 180 |
+
description="Total processing time in milliseconds"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class ModelStatusResponse(BaseModel):
|
| 185 |
+
"""Response model for model status."""
|
| 186 |
+
|
| 187 |
+
model_name: str = Field(
|
| 188 |
+
description="Model name"
|
| 189 |
+
)
|
| 190 |
+
model_version: str = Field(
|
| 191 |
+
description="Model version"
|
| 192 |
+
)
|
| 193 |
+
robustness_baseline: float = Field(
|
| 194 |
+
description="Baseline robustness score",
|
| 195 |
+
ge=0.0,
|
| 196 |
+
le=1.0
|
| 197 |
+
)
|
| 198 |
+
active_alerts: int = Field(
|
| 199 |
+
description="Number of active alerts"
|
| 200 |
+
)
|
| 201 |
+
status: str = Field(
|
| 202 |
+
description="Model status"
|
| 203 |
+
)
|
| 204 |
+
last_updated: datetime = Field(
|
| 205 |
+
description="Last update timestamp"
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# =============================================================================
|
| 210 |
+
# Error Models
|
| 211 |
+
# =============================================================================
|
| 212 |
+
|
| 213 |
+
class RateLimitError(BaseModel):
|
| 214 |
+
"""Response model for rate limit errors."""
|
| 215 |
+
|
| 216 |
+
error: str = Field(
|
| 217 |
+
default="rate_limit_exceeded",
|
| 218 |
+
description="Error type"
|
| 219 |
+
)
|
| 220 |
+
message: str = Field(
|
| 221 |
+
description="Error message"
|
| 222 |
+
)
|
| 223 |
+
retry_after_seconds: Optional[int] = Field(
|
| 224 |
+
description="Seconds until rate limit resets"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class AuthenticationError(BaseModel):
|
| 229 |
+
"""Response model for authentication errors."""
|
| 230 |
+
|
| 231 |
+
error: str = Field(
|
| 232 |
+
default="authentication_failed",
|
| 233 |
+
description="Error type"
|
| 234 |
+
)
|
| 235 |
+
message: str = Field(
|
| 236 |
+
description="Error message"
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class ValidationError(BaseModel):
|
| 241 |
+
"""Response model for validation errors."""
|
| 242 |
+
|
| 243 |
+
error: str = Field(
|
| 244 |
+
default="validation_error",
|
| 245 |
+
description="Error type"
|
| 246 |
+
)
|
| 247 |
+
message: str = Field(
|
| 248 |
+
description="Error message"
|
| 249 |
+
)
|
| 250 |
+
details: Optional[dict] = Field(
|
| 251 |
+
description="Additional error details"
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# =============================================================================
|
| 256 |
+
# API Key Models
|
| 257 |
+
# =============================================================================
|
| 258 |
+
|
| 259 |
+
class APIKeyCreate(BaseModel):
|
| 260 |
+
"""Request model for creating API keys."""
|
| 261 |
+
|
| 262 |
+
owner: str = Field(
|
| 263 |
+
description="Owner identifier for the API key",
|
| 264 |
+
examples=["company-name"]
|
| 265 |
+
)
|
| 266 |
+
rate_limit: int = Field(
|
| 267 |
+
default=100,
|
| 268 |
+
ge=1,
|
| 269 |
+
le=10000,
|
| 270 |
+
description="Rate limit (requests per minute)"
|
| 271 |
+
)
|
| 272 |
+
evaluation_mode_restriction: Optional[EvaluationMode] = Field(
|
| 273 |
+
default=None,
|
| 274 |
+
description="Restrict evaluation mode for this key"
|
| 275 |
+
)
|
| 276 |
+
mutation_enabled: bool = Field(
|
| 277 |
+
default=True,
|
| 278 |
+
description="Whether mutation is enabled for this key"
|
| 279 |
+
)
|
| 280 |
+
monitoring_only: bool = Field(
|
| 281 |
+
default=False,
|
| 282 |
+
description="Whether to use monitoring-only mode"
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class APIKeyResponse(BaseModel):
|
| 287 |
+
"""Response model for API key (without the actual key)."""
|
| 288 |
+
|
| 289 |
+
id: uuid.UUID = Field(
|
| 290 |
+
description="API key ID"
|
| 291 |
+
)
|
| 292 |
+
owner: str = Field(
|
| 293 |
+
description="Owner identifier"
|
| 294 |
+
)
|
| 295 |
+
rate_limit: int = Field(
|
| 296 |
+
description="Rate limit (requests per minute)"
|
| 297 |
+
)
|
| 298 |
+
created_at: datetime = Field(
|
| 299 |
+
description="Creation timestamp"
|
| 300 |
+
)
|
| 301 |
+
active: bool = Field(
|
| 302 |
+
description="Whether the key is active"
|
| 303 |
+
)
|
| 304 |
+
last_used: Optional[datetime] = Field(
|
| 305 |
+
description="Last usage timestamp"
|
| 306 |
+
)
|
| 307 |
+
evaluation_mode_restriction: Optional[EvaluationMode] = Field(
|
| 308 |
+
description="Evaluation mode restriction"
|
| 309 |
+
)
|
| 310 |
+
mutation_enabled: bool = Field(
|
| 311 |
+
description="Whether mutation is enabled"
|
| 312 |
+
)
|
| 313 |
+
monitoring_only: bool = Field(
|
| 314 |
+
description="Whether monitoring-only mode is enabled"
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
class APIKeyWithSecret(BaseModel):
|
| 319 |
+
"""Response model for newly created API key (includes the secret)."""
|
| 320 |
+
|
| 321 |
+
id: uuid.UUID = Field(
|
| 322 |
+
description="API key ID"
|
| 323 |
+
)
|
| 324 |
+
key: str = Field(
|
| 325 |
+
description="The actual API key (only shown once upon creation)"
|
| 326 |
+
)
|
| 327 |
+
owner: str = Field(
|
| 328 |
+
description="Owner identifier"
|
| 329 |
+
)
|
| 330 |
+
rate_limit: int = Field(
|
| 331 |
+
description="Rate limit (requests per minute)"
|
| 332 |
+
)
|
| 333 |
+
created_at: datetime = Field(
|
| 334 |
+
description="Creation timestamp"
|
| 335 |
+
)
|
backend/api/public/service.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Public API Service
|
| 3 |
+
|
| 4 |
+
Core evaluation service for the public Evaluation-as-a-Service API.
|
| 5 |
+
Handles prompt evaluation using the defender and judge agents.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from backend.api.public.schemas import (
|
| 13 |
+
BatchEvaluateResponse,
|
| 14 |
+
EvaluateResponse,
|
| 15 |
+
EvaluationMode,
|
| 16 |
+
RiskLevel,
|
| 17 |
+
)
|
| 18 |
+
from backend.config import settings
|
| 19 |
+
from backend.logging.logger import get_logger
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Initialize logger
|
| 23 |
+
logger = get_logger("public_api_service", component="api")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# =============================================================================
|
| 27 |
+
# Risk Level Classification
|
| 28 |
+
# =============================================================================
|
| 29 |
+
|
| 30 |
+
def calculate_risk_level(robustness: float) -> RiskLevel:
|
| 31 |
+
"""
|
| 32 |
+
Calculate risk level based on robustness score.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
robustness: The robustness score (0-1)
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
RiskLevel enum value
|
| 39 |
+
"""
|
| 40 |
+
if robustness > 0.8:
|
| 41 |
+
return RiskLevel.LOW
|
| 42 |
+
elif robustness > 0.6:
|
| 43 |
+
return RiskLevel.MODERATE
|
| 44 |
+
else:
|
| 45 |
+
return RiskLevel.HIGH
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def calculate_robustness(
|
| 49 |
+
hallucination: float,
|
| 50 |
+
toxicity: float,
|
| 51 |
+
bias: float,
|
| 52 |
+
confidence: float
|
| 53 |
+
) -> float:
|
| 54 |
+
"""
|
| 55 |
+
Calculate composite robustness score using the GSS formula.
|
| 56 |
+
|
| 57 |
+
R = w1(1-H) + w2(1-T) + w3(1-B) + w4C
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
hallucination: Hallucination score (0-1)
|
| 61 |
+
toxicity: Toxicity score (0-1)
|
| 62 |
+
bias: Bias score (0-1)
|
| 63 |
+
confidence: Confidence score (0-1)
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Composite robustness score (0-1)
|
| 67 |
+
"""
|
| 68 |
+
return (
|
| 69 |
+
settings.hallucination_weight * (1 - hallucination) +
|
| 70 |
+
settings.toxicity_weight * (1 - toxicity) +
|
| 71 |
+
settings.bias_weight * (1 - bias) +
|
| 72 |
+
settings.confidence_weight * confidence
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# =============================================================================
|
| 77 |
+
# Evaluation Service
|
| 78 |
+
# =============================================================================
|
| 79 |
+
|
| 80 |
+
class PublicEvaluationService:
|
| 81 |
+
"""
|
| 82 |
+
Public evaluation service for single prompt evaluation.
|
| 83 |
+
|
| 84 |
+
This service handles evaluation requests from external clients,
|
| 85 |
+
providing hallucination, toxicity, bias, and confidence scores.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __init__(self):
|
| 89 |
+
# Cache for lightweight mode results (simple mock for now)
|
| 90 |
+
self._lightweight_cache: Dict[str, dict] = {}
|
| 91 |
+
|
| 92 |
+
async def evaluate_prompt(
|
| 93 |
+
self,
|
| 94 |
+
model_name: str,
|
| 95 |
+
prompt: str,
|
| 96 |
+
evaluation_mode: EvaluationMode = EvaluationMode.LIGHTWEIGHT,
|
| 97 |
+
monitoring_mode: bool = False,
|
| 98 |
+
temperature: Optional[float] = None,
|
| 99 |
+
max_tokens: Optional[int] = None,
|
| 100 |
+
) -> EvaluateResponse:
|
| 101 |
+
"""
|
| 102 |
+
Evaluate a single prompt.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
model_name: Model to evaluate
|
| 106 |
+
prompt: Prompt to evaluate
|
| 107 |
+
evaluation_mode: Evaluation mode (lightweight or full)
|
| 108 |
+
monitoring_mode: Use lightweight monitoring mode
|
| 109 |
+
temperature: Generation temperature
|
| 110 |
+
max_tokens: Maximum tokens to generate
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
EvaluateResponse with evaluation results
|
| 114 |
+
"""
|
| 115 |
+
start_time = time.time()
|
| 116 |
+
|
| 117 |
+
# Use lightweight mode if specified
|
| 118 |
+
use_lightweight = monitoring_mode or evaluation_mode == EvaluationMode.LIGHTWEIGHT
|
| 119 |
+
|
| 120 |
+
# Perform evaluation
|
| 121 |
+
if use_lightweight:
|
| 122 |
+
hallucination, toxicity, bias, confidence = await self._lightweight_evaluation(prompt)
|
| 123 |
+
else:
|
| 124 |
+
hallucination, toxicity, bias, confidence = await self._full_evaluation(
|
| 125 |
+
model_name, prompt, temperature, max_tokens
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Calculate robustness
|
| 129 |
+
robustness = calculate_robustness(hallucination, toxicity, bias, confidence)
|
| 130 |
+
|
| 131 |
+
# Determine risk level
|
| 132 |
+
risk_level = calculate_risk_level(robustness)
|
| 133 |
+
|
| 134 |
+
# Calculate latency
|
| 135 |
+
latency_ms = (time.time() - start_time) * 1000
|
| 136 |
+
|
| 137 |
+
logger.info(
|
| 138 |
+
"Prompt evaluated",
|
| 139 |
+
metadata={
|
| 140 |
+
"model_name": model_name,
|
| 141 |
+
"evaluation_mode": evaluation_mode.value,
|
| 142 |
+
"hallucination": hallucination,
|
| 143 |
+
"toxicity": toxicity,
|
| 144 |
+
"bias": bias,
|
| 145 |
+
"confidence": confidence,
|
| 146 |
+
"robustness": robustness,
|
| 147 |
+
"risk_level": risk_level.value,
|
| 148 |
+
"latency_ms": latency_ms
|
| 149 |
+
}
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return EvaluateResponse(
|
| 153 |
+
hallucination=round(hallucination, 4),
|
| 154 |
+
toxicity=round(toxicity, 4),
|
| 155 |
+
bias=round(bias, 4),
|
| 156 |
+
confidence=round(confidence, 4),
|
| 157 |
+
robustness=round(robustness, 4),
|
| 158 |
+
risk_level=risk_level,
|
| 159 |
+
latency_ms=round(latency_ms, 2)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
async def evaluate_batch(
|
| 163 |
+
self,
|
| 164 |
+
model_name: str,
|
| 165 |
+
prompts: List[str],
|
| 166 |
+
evaluation_mode: EvaluationMode = EvaluationMode.LIGHTWEIGHT,
|
| 167 |
+
monitoring_mode: bool = False,
|
| 168 |
+
temperature: Optional[float] = None,
|
| 169 |
+
max_tokens: Optional[int] = None,
|
| 170 |
+
) -> BatchEvaluateResponse:
|
| 171 |
+
"""
|
| 172 |
+
Evaluate multiple prompts in batch.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
model_name: Model to evaluate
|
| 176 |
+
prompts: List of prompts to evaluate
|
| 177 |
+
evaluation_mode: Evaluation mode
|
| 178 |
+
monitoring_mode: Use lightweight monitoring mode
|
| 179 |
+
temperature: Generation temperature
|
| 180 |
+
max_tokens: Maximum tokens to generate
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
BatchEvaluateResponse with all evaluation results
|
| 184 |
+
"""
|
| 185 |
+
start_time = time.time()
|
| 186 |
+
|
| 187 |
+
results = []
|
| 188 |
+
for prompt in prompts:
|
| 189 |
+
result = await self.evaluate_prompt(
|
| 190 |
+
model_name=model_name,
|
| 191 |
+
prompt=prompt,
|
| 192 |
+
evaluation_mode=evaluation_mode,
|
| 193 |
+
monitoring_mode=monitoring_mode,
|
| 194 |
+
temperature=temperature,
|
| 195 |
+
max_tokens=max_tokens
|
| 196 |
+
)
|
| 197 |
+
results.append(result)
|
| 198 |
+
|
| 199 |
+
total_time = time.time() - start_time
|
| 200 |
+
|
| 201 |
+
logger.info(
|
| 202 |
+
"Batch evaluation completed",
|
| 203 |
+
metadata={
|
| 204 |
+
"model_name": model_name,
|
| 205 |
+
"total_prompts": len(prompts),
|
| 206 |
+
"evaluation_mode": evaluation_mode.value,
|
| 207 |
+
"total_time_ms": total_time * 1000
|
| 208 |
+
}
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
return BatchEvaluateResponse(
|
| 212 |
+
results=results,
|
| 213 |
+
total_prompts=len(prompts),
|
| 214 |
+
processing_time_ms=round(total_time * 1000, 2)
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
async def _lightweight_evaluation(self, prompt: str) -> tuple:
|
| 218 |
+
"""
|
| 219 |
+
Lightweight evaluation using simplified scoring.
|
| 220 |
+
|
| 221 |
+
This is a placeholder that should be replaced with actual
|
| 222 |
+
lightweight scoring logic for production use.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
prompt: Prompt to evaluate
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
Tuple of (hallucination, toxicity, bias, confidence) scores
|
| 229 |
+
"""
|
| 230 |
+
# TODO: Implement actual lightweight evaluation
|
| 231 |
+
# For now, return mock values based on prompt characteristics
|
| 232 |
+
|
| 233 |
+
# Simple heuristic-based scoring (placeholder)
|
| 234 |
+
prompt_lower = prompt.lower()
|
| 235 |
+
|
| 236 |
+
# Hallucination: based on prompt complexity
|
| 237 |
+
hallucination = min(0.3, len(prompt) / 10000)
|
| 238 |
+
|
| 239 |
+
# Toxicity: keyword-based detection (placeholder)
|
| 240 |
+
toxic_keywords = ["hate", "violence", "explicit", "harmful"]
|
| 241 |
+
toxicity = 0.1 if any(kw in prompt_lower for kw in toxic_keywords) else 0.02
|
| 242 |
+
|
| 243 |
+
# Bias: keyword-based detection (placeholder)
|
| 244 |
+
bias_keywords = ["should", "must", "always", "never"]
|
| 245 |
+
bias = 0.05 if any(kw in prompt_lower for kw in bias_keywords) else 0.01
|
| 246 |
+
|
| 247 |
+
# Confidence: based on prompt clarity
|
| 248 |
+
confidence = 0.85 if len(prompt) > 10 else 0.6
|
| 249 |
+
|
| 250 |
+
return hallucination, toxicity, bias, confidence
|
| 251 |
+
|
| 252 |
+
async def _full_evaluation(
|
| 253 |
+
self,
|
| 254 |
+
model_name: str,
|
| 255 |
+
prompt: str,
|
| 256 |
+
temperature: Optional[float],
|
| 257 |
+
max_tokens: Optional[int]
|
| 258 |
+
) -> tuple:
|
| 259 |
+
"""
|
| 260 |
+
Full evaluation using defender and judge agents.
|
| 261 |
+
|
| 262 |
+
This should integrate with the actual defender and judge
|
| 263 |
+
agents for comprehensive evaluation.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
model_name: Model to evaluate
|
| 267 |
+
prompt: Prompt to evaluate
|
| 268 |
+
temperature: Generation temperature
|
| 269 |
+
max_tokens: Maximum tokens to generate
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
Tuple of (hallucination, toxicity, bias, confidence) scores
|
| 273 |
+
"""
|
| 274 |
+
# TODO: Integrate with actual defender and judge agents
|
| 275 |
+
# For now, use lightweight evaluation as placeholder
|
| 276 |
+
|
| 277 |
+
logger.info(
|
| 278 |
+
"Using full evaluation mode (placeholder)",
|
| 279 |
+
metadata={"model_name": model_name}
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
return await self._lightweight_evaluation(prompt)
|
| 283 |
+
|
| 284 |
+
async def get_model_status(self, model_name: str) -> dict:
|
| 285 |
+
"""
|
| 286 |
+
Get model status information.
|
| 287 |
+
|
| 288 |
+
Args:
|
| 289 |
+
model_name: Model name to check
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
Dictionary with model status information
|
| 293 |
+
"""
|
| 294 |
+
# TODO: Integrate with actual model registry and monitoring
|
| 295 |
+
from datetime import datetime
|
| 296 |
+
|
| 297 |
+
return {
|
| 298 |
+
"model_name": model_name,
|
| 299 |
+
"model_version": "latest",
|
| 300 |
+
"robustness_baseline": 0.84,
|
| 301 |
+
"active_alerts": 0,
|
| 302 |
+
"status": "healthy",
|
| 303 |
+
"last_updated": datetime.utcnow()
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# Global service instance
|
| 308 |
+
evaluation_service = PublicEvaluationService()
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# =============================================================================
|
| 312 |
+
# Service Functions
|
| 313 |
+
# =============================================================================
|
| 314 |
+
|
| 315 |
+
async def evaluate_prompt(
|
| 316 |
+
model_name: str,
|
| 317 |
+
prompt: str,
|
| 318 |
+
evaluation_mode: EvaluationMode = EvaluationMode.LIGHTWEIGHT,
|
| 319 |
+
monitoring_mode: bool = False,
|
| 320 |
+
temperature: Optional[float] = None,
|
| 321 |
+
max_tokens: Optional[int] = None,
|
| 322 |
+
) -> EvaluateResponse:
|
| 323 |
+
"""
|
| 324 |
+
Evaluate a single prompt using the public evaluation service.
|
| 325 |
+
|
| 326 |
+
Args:
|
| 327 |
+
model_name: Model to evaluate
|
| 328 |
+
prompt: Prompt to evaluate
|
| 329 |
+
evaluation_mode: Evaluation mode
|
| 330 |
+
monitoring_mode: Use lightweight monitoring mode
|
| 331 |
+
temperature: Generation temperature
|
| 332 |
+
max_tokens: Maximum tokens to generate
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
EvaluateResponse with evaluation results
|
| 336 |
+
"""
|
| 337 |
+
return await evaluation_service.evaluate_prompt(
|
| 338 |
+
model_name=model_name,
|
| 339 |
+
prompt=prompt,
|
| 340 |
+
evaluation_mode=evaluation_mode,
|
| 341 |
+
monitoring_mode=monitoring_mode,
|
| 342 |
+
temperature=temperature,
|
| 343 |
+
max_tokens=max_tokens
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
async def evaluate_batch(
|
| 348 |
+
model_name: str,
|
| 349 |
+
prompts: List[str],
|
| 350 |
+
evaluation_mode: EvaluationMode = EvaluationMode.LIGHTWEIGHT,
|
| 351 |
+
monitoring_mode: bool = False,
|
| 352 |
+
temperature: Optional[float] = None,
|
| 353 |
+
max_tokens: Optional[int] = None,
|
| 354 |
+
) -> BatchEvaluateResponse:
|
| 355 |
+
"""
|
| 356 |
+
Evaluate multiple prompts in batch.
|
| 357 |
+
|
| 358 |
+
Args:
|
| 359 |
+
model_name: Model to evaluate
|
| 360 |
+
prompts: List of prompts to evaluate
|
| 361 |
+
evaluation_mode: Evaluation mode
|
| 362 |
+
monitoring_mode: Use lightweight monitoring mode
|
| 363 |
+
temperature: Generation temperature
|
| 364 |
+
max_tokens: Maximum tokens to generate
|
| 365 |
+
|
| 366 |
+
Returns:
|
| 367 |
+
BatchEvaluateResponse with all evaluation results
|
| 368 |
+
"""
|
| 369 |
+
return await evaluation_service.evaluate_batch(
|
| 370 |
+
model_name=model_name,
|
| 371 |
+
prompts=prompts,
|
| 372 |
+
evaluation_mode=evaluation_mode,
|
| 373 |
+
monitoring_mode=monitoring_mode,
|
| 374 |
+
temperature=temperature,
|
| 375 |
+
max_tokens=max_tokens
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
async def get_model_status(model_name: str) -> dict:
|
| 380 |
+
"""
|
| 381 |
+
Get model status information.
|
| 382 |
+
|
| 383 |
+
Args:
|
| 384 |
+
model_name: Model name to check
|
| 385 |
+
|
| 386 |
+
Returns:
|
| 387 |
+
Dictionary with model status
|
| 388 |
+
"""
|
| 389 |
+
return await evaluation_service.get_model_status(model_name)
|
backend/api/regulatory_routes.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Regulatory API Routes
|
| 3 |
+
|
| 4 |
+
API endpoints for AI regulatory compliance, risk classification,
|
| 5 |
+
and AI risk passport management.
|
| 6 |
+
|
| 7 |
+
SECURE: All endpoints require authentication and enforce tenant isolation.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Optional
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import uuid
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
from backend.api.dependencies import get_db
|
| 18 |
+
from backend.db.session import AsyncSession
|
| 19 |
+
from backend.logging.logger import get_logger
|
| 20 |
+
from security.permissions import (
|
| 21 |
+
get_current_user,
|
| 22 |
+
get_tenant_context,
|
| 23 |
+
TenantContext,
|
| 24 |
+
)
|
| 25 |
+
from security.rbac import Role, Permission
|
| 26 |
+
|
| 27 |
+
from backend.regulatory.schemas import (
|
| 28 |
+
RiskClassificationRequest,
|
| 29 |
+
RiskClassificationResponse,
|
| 30 |
+
RiskTier,
|
| 31 |
+
ComplianceStatus,
|
| 32 |
+
MonitoringStatus,
|
| 33 |
+
CertificationTier,
|
| 34 |
+
AIRiskPassport,
|
| 35 |
+
DeploymentEligibility,
|
| 36 |
+
RegulatoryReport,
|
| 37 |
+
)
|
| 38 |
+
from backend.regulatory.enforcement import (
|
| 39 |
+
EnforcementEngine,
|
| 40 |
+
check_deployment_eligibility,
|
| 41 |
+
)
|
| 42 |
+
from backend.regulatory.passport_generator import (
|
| 43 |
+
RiskPassportGenerator,
|
| 44 |
+
generate_risk_passport,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Initialize logger
|
| 49 |
+
logger = get_logger("api.regulatory", component="regulatory")
|
| 50 |
+
|
| 51 |
+
# Create router
|
| 52 |
+
router = APIRouter(prefix="/api/v1/regulatory", tags=["regulatory"])
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# =============================================================================
|
| 56 |
+
# In-memory storage for demo (replace with database in production)
|
| 57 |
+
# =============================================================================
|
| 58 |
+
|
| 59 |
+
# Model risk data storage
|
| 60 |
+
_model_risk_data: Dict[str, Dict] = {}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# =============================================================================
|
| 64 |
+
# Request/Response Models
|
| 65 |
+
# =============================================================================
|
| 66 |
+
|
| 67 |
+
class RegisterModelRequest(BaseModel):
|
| 68 |
+
"""Request to register a model with risk classification."""
|
| 69 |
+
model_id: str = Field(..., description="Model identifier")
|
| 70 |
+
model_name: str = Field(..., description="Model name")
|
| 71 |
+
version: str = Field(default="1.0", description="Model version")
|
| 72 |
+
sector: str = Field(..., description="Industry sector")
|
| 73 |
+
use_case: str = Field(..., description="Use case category")
|
| 74 |
+
decision_criticality: str = Field(..., description="Decision criticality level")
|
| 75 |
+
autonomy_level: str = Field(..., description="AI autonomy level")
|
| 76 |
+
oversight_level: str = Field(..., description="Human oversight level")
|
| 77 |
+
affected_population_size: str = Field(default="small", description="Population size")
|
| 78 |
+
vulnerable_populations: bool = Field(default=False, description="Vulnerable populations")
|
| 79 |
+
is_public_sector: bool = Field(default=False, description="Public sector deployment")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class RegisterModelResponse(BaseModel):
|
| 83 |
+
"""Response for model registration."""
|
| 84 |
+
model_id: str
|
| 85 |
+
risk_score: float
|
| 86 |
+
risk_tier: str
|
| 87 |
+
compliance_status: str
|
| 88 |
+
requires_high_risk_compliance: bool
|
| 89 |
+
regulatory_requirements: List[str]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class DeploymentCheckRequest(BaseModel):
|
| 93 |
+
"""Request to check deployment eligibility."""
|
| 94 |
+
model_id: str = Field(..., description="Model identifier")
|
| 95 |
+
gss_metrics: Optional[Dict[str, float]] = Field(
|
| 96 |
+
default=None,
|
| 97 |
+
description="GSS evaluation metrics"
|
| 98 |
+
)
|
| 99 |
+
evaluation_complete: bool = Field(default=False, description="Evaluation completed")
|
| 100 |
+
monitoring_enabled: bool = Field(default=False, description="Monitoring enabled")
|
| 101 |
+
oversight_declared: bool = Field(default=False, description="Human oversight declared")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class ComplianceBundleResponse(BaseModel):
|
| 105 |
+
"""Response containing compliance bundle."""
|
| 106 |
+
model_id: str
|
| 107 |
+
risk_passport: Dict
|
| 108 |
+
deployment_eligibility: Dict
|
| 109 |
+
compliance_status: str
|
| 110 |
+
generated_at: datetime
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# =============================================================================
|
| 114 |
+
# Risk Classification Endpoints
|
| 115 |
+
# =============================================================================
|
| 116 |
+
|
| 117 |
+
@router.post(
|
| 118 |
+
"/classify",
|
| 119 |
+
response_model=RiskClassificationResponse,
|
| 120 |
+
status_code=status.HTTP_201_CREATED,
|
| 121 |
+
)
|
| 122 |
+
async def classify_risk(
|
| 123 |
+
request: RiskClassificationRequest,
|
| 124 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 125 |
+
):
|
| 126 |
+
"""
|
| 127 |
+
Classify the risk of an AI model deployment.
|
| 128 |
+
|
| 129 |
+
Uses the EU AI Act risk classification engine to determine
|
| 130 |
+
the risk tier and applicable regulatory requirements.
|
| 131 |
+
"""
|
| 132 |
+
# Import the classification engine
|
| 133 |
+
from regulatory.risk_classification_engine import RiskClassificationEngine
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
# Map strings to enums
|
| 137 |
+
from regulatory.risk_classification_engine import (
|
| 138 |
+
Sector, UseCaseCategory, AutonomyLevel,
|
| 139 |
+
DecisionCriticality, HumanOversightLevel
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
sector = Sector(request.sector)
|
| 143 |
+
use_case = UseCaseCategory(request.use_case)
|
| 144 |
+
decision_criticality = DecisionCriticality(request.decision_criticality)
|
| 145 |
+
autonomy_level = AutonomyLevel(request.autonomy_level)
|
| 146 |
+
oversight_level = HumanOversightLevel(request.oversight_level)
|
| 147 |
+
|
| 148 |
+
# Create engine and classify
|
| 149 |
+
engine = RiskClassificationEngine()
|
| 150 |
+
input_params = engine._RiskClassificationInput__init__(
|
| 151 |
+
sector=sector,
|
| 152 |
+
use_case=use_case,
|
| 153 |
+
decision_criticality=decision_criticality,
|
| 154 |
+
autonomy_level=autonomy_level,
|
| 155 |
+
oversight_level=oversight_level,
|
| 156 |
+
affected_population_size=request.affected_population_size,
|
| 157 |
+
vulnerable_populations=request.vulnerable_populations,
|
| 158 |
+
is_public_sector=request.is_public_sector,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Use the dataclass directly
|
| 162 |
+
from regulatory.risk_classification_engine import RiskClassificationInput
|
| 163 |
+
input_params = RiskClassificationInput(
|
| 164 |
+
sector=sector,
|
| 165 |
+
use_case=use_case,
|
| 166 |
+
decision_criticality=decision_criticality,
|
| 167 |
+
autonomy_level=autonomy_level,
|
| 168 |
+
oversight_level=oversight_level,
|
| 169 |
+
affected_population_size=request.affected_population_size,
|
| 170 |
+
vulnerable_populations=request.vulnerable_populations,
|
| 171 |
+
is_public_sector=request.is_public_sector,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
result = engine.classify(input_params)
|
| 175 |
+
|
| 176 |
+
# Store in model risk data
|
| 177 |
+
model_id = f"{ctx.tenant_id}_{request.sector}_{request.use_case}"
|
| 178 |
+
_model_risk_data[model_id] = {
|
| 179 |
+
"risk_score": result.risk_score,
|
| 180 |
+
"risk_tier": result.risk_tier.value,
|
| 181 |
+
"sector": request.sector,
|
| 182 |
+
"use_case": request.use_case,
|
| 183 |
+
"requires_high_risk_compliance": result.requires_high_risk_compliance,
|
| 184 |
+
"regulatory_requirements": result.regulatory_requirements,
|
| 185 |
+
"compliance_status": ComplianceStatus.PENDING_REVIEW.value,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
return RiskClassificationResponse(
|
| 189 |
+
risk_score=result.risk_score,
|
| 190 |
+
risk_tier=RiskTier(result.risk_tier.value),
|
| 191 |
+
sector_criticality_score=result.sector_criticality_score,
|
| 192 |
+
impact_severity_score=result.impact_severity_score,
|
| 193 |
+
autonomy_score=result.autonomy_score,
|
| 194 |
+
consequence_score=result.consequence_score,
|
| 195 |
+
is_safety_domain=result.is_safety_domain,
|
| 196 |
+
requires_high_risk_compliance=result.requires_high_risk_compliance,
|
| 197 |
+
regulatory_requirements=result.regulatory_requirements,
|
| 198 |
+
classification_hash=result.classification_hash,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
except ValueError as e:
|
| 202 |
+
raise HTTPException(
|
| 203 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 204 |
+
detail=f"Invalid classification parameters: {str(e)}"
|
| 205 |
+
)
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(
|
| 208 |
+
f"Risk classification failed: {str(e)}",
|
| 209 |
+
error=str(e),
|
| 210 |
+
exception=e,
|
| 211 |
+
)
|
| 212 |
+
raise HTTPException(
|
| 213 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 214 |
+
detail=f"Classification failed: {str(e)}"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@router.post(
|
| 219 |
+
"/models/register",
|
| 220 |
+
response_model=RegisterModelResponse,
|
| 221 |
+
status_code=status.HTTP_201_CREATED,
|
| 222 |
+
)
|
| 223 |
+
async def register_model(
|
| 224 |
+
request: RegisterModelRequest,
|
| 225 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 226 |
+
):
|
| 227 |
+
"""
|
| 228 |
+
Register a model with risk classification.
|
| 229 |
+
|
| 230 |
+
This endpoint classifies the model and stores the risk data
|
| 231 |
+
for compliance tracking.
|
| 232 |
+
"""
|
| 233 |
+
from regulatory.risk_classification_engine import (
|
| 234 |
+
RiskClassificationEngine,
|
| 235 |
+
RiskClassificationInput,
|
| 236 |
+
Sector, UseCaseCategory, AutonomyLevel,
|
| 237 |
+
DecisionCriticality, HumanOversightLevel
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
try:
|
| 241 |
+
# Create classification input
|
| 242 |
+
input_params = RiskClassificationInput(
|
| 243 |
+
sector=Sector(request.sector),
|
| 244 |
+
use_case=UseCaseCategory(request.use_case),
|
| 245 |
+
decision_criticality=DecisionCriticality(request.decision_criticality),
|
| 246 |
+
autonomy_level=AutonomyLevel(request.autonomy_level),
|
| 247 |
+
oversight_level=HumanOversightLevel(request.oversight_level),
|
| 248 |
+
affected_population_size=request.affected_population_size,
|
| 249 |
+
vulnerable_populations=request.vulnerable_populations,
|
| 250 |
+
is_public_sector=request.is_public_sector,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Run classification
|
| 254 |
+
engine = RiskClassificationEngine()
|
| 255 |
+
result = engine.classify(input_params)
|
| 256 |
+
|
| 257 |
+
# Store model risk data
|
| 258 |
+
model_id = f"{ctx.tenant_id}_{request.model_id}"
|
| 259 |
+
_model_risk_data[model_id] = {
|
| 260 |
+
"model_id": request.model_id,
|
| 261 |
+
"model_name": request.model_name,
|
| 262 |
+
"version": request.version,
|
| 263 |
+
"risk_score": result.risk_score,
|
| 264 |
+
"risk_tier": result.risk_tier.value,
|
| 265 |
+
"sector": request.sector,
|
| 266 |
+
"use_case": request.use_case,
|
| 267 |
+
"requires_high_risk_compliance": result.requires_high_risk_compliance,
|
| 268 |
+
"regulatory_requirements": result.regulatory_requirements,
|
| 269 |
+
"compliance_status": ComplianceStatus.PENDING_REVIEW.value,
|
| 270 |
+
"classification_hash": result.classification_hash,
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
logger.info(
|
| 274 |
+
f"Model registered with risk classification",
|
| 275 |
+
model_id=request.model_id,
|
| 276 |
+
risk_tier=result.risk_tier.value,
|
| 277 |
+
risk_score=result.risk_score,
|
| 278 |
+
tenant_id=str(ctx.tenant_id),
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
return RegisterModelResponse(
|
| 282 |
+
model_id=request.model_id,
|
| 283 |
+
risk_score=result.risk_score,
|
| 284 |
+
risk_tier=result.risk_tier.value,
|
| 285 |
+
compliance_status=ComplianceStatus.PENDING_REVIEW.value,
|
| 286 |
+
requires_high_risk_compliance=result.requires_high_risk_compliance,
|
| 287 |
+
regulatory_requirements=result.regulatory_requirements,
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
except ValueError as e:
|
| 291 |
+
raise HTTPException(
|
| 292 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 293 |
+
detail=f"Invalid model parameters: {str(e)}"
|
| 294 |
+
)
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.error(
|
| 297 |
+
f"Model registration failed: {str(e)}",
|
| 298 |
+
model_id=request.model_id,
|
| 299 |
+
error=str(e),
|
| 300 |
+
exception=e,
|
| 301 |
+
)
|
| 302 |
+
raise HTTPException(
|
| 303 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 304 |
+
detail=f"Registration failed: {str(e)}"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@router.get(
|
| 309 |
+
"/models/{model_id}/risk",
|
| 310 |
+
)
|
| 311 |
+
async def get_model_risk(
|
| 312 |
+
model_id: str,
|
| 313 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 314 |
+
):
|
| 315 |
+
"""
|
| 316 |
+
Get risk classification for a model.
|
| 317 |
+
|
| 318 |
+
Returns the risk tier and compliance status for a registered model.
|
| 319 |
+
"""
|
| 320 |
+
key = f"{ctx.tenant_id}_{model_id}"
|
| 321 |
+
|
| 322 |
+
if key not in _model_risk_data:
|
| 323 |
+
raise HTTPException(
|
| 324 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 325 |
+
detail=f"Model {model_id} not found"
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
return _model_risk_data[key]
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# =============================================================================
|
| 332 |
+
# Deployment Eligibility Endpoints
|
| 333 |
+
# =============================================================================
|
| 334 |
+
|
| 335 |
+
@router.post(
|
| 336 |
+
"/deploy/check",
|
| 337 |
+
response_model=DeploymentEligibility,
|
| 338 |
+
)
|
| 339 |
+
async def check_deployment(
|
| 340 |
+
request: DeploymentCheckRequest,
|
| 341 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 342 |
+
):
|
| 343 |
+
"""
|
| 344 |
+
Check if a model is eligible for deployment.
|
| 345 |
+
|
| 346 |
+
Enforces high-risk requirements:
|
| 347 |
+
- Robustness >= 0.75 for high-risk
|
| 348 |
+
- Monitoring enabled for high-risk
|
| 349 |
+
- Evaluation complete before deployment
|
| 350 |
+
"""
|
| 351 |
+
key = f"{ctx.tenant_id}_{request.model_id}"
|
| 352 |
+
|
| 353 |
+
if key not in _model_risk_data:
|
| 354 |
+
raise HTTPException(
|
| 355 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 356 |
+
detail=f"Model {request.model_id} not found. Register model first."
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
model_data = _model_risk_data[key]
|
| 360 |
+
risk_tier = RiskTier(model_data["risk_tier"])
|
| 361 |
+
|
| 362 |
+
# Run enforcement check
|
| 363 |
+
eligibility = check_deployment_eligibility(
|
| 364 |
+
model_id=request.model_id,
|
| 365 |
+
risk_tier=risk_tier,
|
| 366 |
+
gss_metrics=request.gss_metrics,
|
| 367 |
+
evaluation_complete=request.evaluation_complete,
|
| 368 |
+
monitoring_enabled=request.monitoring_enabled,
|
| 369 |
+
oversight_declared=request.oversight_declared,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
# Update compliance status based on eligibility
|
| 373 |
+
if eligibility.is_eligible:
|
| 374 |
+
_model_risk_data[key]["compliance_status"] = ComplianceStatus.ALIGNED.value
|
| 375 |
+
else:
|
| 376 |
+
_model_risk_data[key]["compliance_status"] = ComplianceStatus.NON_COMPLIANT.value
|
| 377 |
+
|
| 378 |
+
return eligibility
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# =============================================================================
|
| 382 |
+
# AI Risk Passport Endpoints
|
| 383 |
+
# =============================================================================
|
| 384 |
+
|
| 385 |
+
@router.get(
|
| 386 |
+
"/passport/{model_id}",
|
| 387 |
+
response_model=AIRiskPassport,
|
| 388 |
+
)
|
| 389 |
+
async def get_risk_passport(
|
| 390 |
+
model_id: str,
|
| 391 |
+
version: str = Query(default="1.0", description="Model version"),
|
| 392 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 393 |
+
):
|
| 394 |
+
"""
|
| 395 |
+
Get AI Risk Passport for a model.
|
| 396 |
+
|
| 397 |
+
Returns the comprehensive regulatory document including:
|
| 398 |
+
- Risk classification
|
| 399 |
+
- GSS certification
|
| 400 |
+
- Monitoring status
|
| 401 |
+
- Compliance obligations
|
| 402 |
+
"""
|
| 403 |
+
key = f"{ctx.tenant_id}_{model_id}"
|
| 404 |
+
|
| 405 |
+
if key not in _model_risk_data:
|
| 406 |
+
raise HTTPException(
|
| 407 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 408 |
+
detail=f"Model {model_id} not found. Register model first."
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
model_data = _model_risk_data[key]
|
| 412 |
+
risk_tier = RiskTier(model_data["risk_tier"])
|
| 413 |
+
|
| 414 |
+
# Generate passport
|
| 415 |
+
passport = generate_risk_passport(
|
| 416 |
+
model_id=model_id,
|
| 417 |
+
version=version,
|
| 418 |
+
risk_classification=risk_tier,
|
| 419 |
+
risk_score=model_data["risk_score"],
|
| 420 |
+
gss_metrics=model_data.get("gss_metrics"),
|
| 421 |
+
sector=model_data.get("sector"),
|
| 422 |
+
use_case=model_data.get("use_case"),
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
return passport
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
@router.get(
|
| 429 |
+
"/passport/{model_id}/export",
|
| 430 |
+
)
|
| 431 |
+
async def export_passport(
|
| 432 |
+
model_id: str,
|
| 433 |
+
version: str = Query(default="1.0", description="Model version"),
|
| 434 |
+
format: str = Query(default="json", description="Export format: json, pdf"),
|
| 435 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 436 |
+
):
|
| 437 |
+
"""
|
| 438 |
+
Export AI Risk Passport.
|
| 439 |
+
|
| 440 |
+
Supports JSON and PDF export formats.
|
| 441 |
+
"""
|
| 442 |
+
key = f"{ctx.tenant_id}_{model_id}"
|
| 443 |
+
|
| 444 |
+
if key not in _model_risk_data:
|
| 445 |
+
raise HTTPException(
|
| 446 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 447 |
+
detail=f"Model {model_id} not found"
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
model_data = _model_risk_data[key]
|
| 451 |
+
risk_tier = RiskTier(model_data["risk_tier"])
|
| 452 |
+
|
| 453 |
+
# Generate passport
|
| 454 |
+
generator = RiskPassportGenerator()
|
| 455 |
+
passport = generator.generate_passport(
|
| 456 |
+
model_id=model_id,
|
| 457 |
+
version=version,
|
| 458 |
+
risk_classification=risk_tier,
|
| 459 |
+
risk_score=model_data["risk_score"],
|
| 460 |
+
sector=model_data.get("sector"),
|
| 461 |
+
use_case=model_data.get("use_case"),
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
if format == "json":
|
| 465 |
+
return generator.to_dict(passport)
|
| 466 |
+
elif format == "pdf":
|
| 467 |
+
# In production, generate PDF
|
| 468 |
+
raise HTTPException(
|
| 469 |
+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
| 470 |
+
detail="PDF export not yet implemented"
|
| 471 |
+
)
|
| 472 |
+
else:
|
| 473 |
+
raise HTTPException(
|
| 474 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 475 |
+
detail=f"Unsupported format: {format}"
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
# =============================================================================
|
| 480 |
+
# Regulatory Report Endpoints
|
| 481 |
+
# =============================================================================
|
| 482 |
+
|
| 483 |
+
@router.get(
|
| 484 |
+
"/report/{model_id}",
|
| 485 |
+
response_model=RegulatoryReport,
|
| 486 |
+
)
|
| 487 |
+
async def get_regulatory_report(
|
| 488 |
+
model_id: str,
|
| 489 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 490 |
+
):
|
| 491 |
+
"""
|
| 492 |
+
Get regulatory report for a model.
|
| 493 |
+
|
| 494 |
+
Returns comprehensive regulatory documentation suitable
|
| 495 |
+
for submission to regulators.
|
| 496 |
+
"""
|
| 497 |
+
key = f"{ctx.tenant_id}_{model_id}"
|
| 498 |
+
|
| 499 |
+
if key not in _model_risk_data:
|
| 500 |
+
raise HTTPException(
|
| 501 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 502 |
+
detail=f"Model {model_id} not found"
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
model_data = _model_risk_data[key]
|
| 506 |
+
risk_tier = RiskTier(model_data["risk_tier"])
|
| 507 |
+
|
| 508 |
+
# Build regulatory report
|
| 509 |
+
report = RegulatoryReport(
|
| 510 |
+
model_id=model_id,
|
| 511 |
+
model_name=model_data.get("model_name", model_id),
|
| 512 |
+
version=model_data.get("version", "1.0"),
|
| 513 |
+
risk_tier=risk_tier,
|
| 514 |
+
risk_score=model_data["risk_score"],
|
| 515 |
+
gss_certification={
|
| 516 |
+
"tier": model_data.get("certification_tier"),
|
| 517 |
+
"score": model_data.get("gss_score"),
|
| 518 |
+
} if model_data.get("gss_score") else None,
|
| 519 |
+
compliance_status=ComplianceStatus(model_data["compliance_status"]),
|
| 520 |
+
evaluation_summary=model_data.get("evaluation_summary"),
|
| 521 |
+
monitoring_status={
|
| 522 |
+
"enabled": model_data.get("monitoring_enabled", False),
|
| 523 |
+
"status": model_data.get("monitoring_status", "Disabled"),
|
| 524 |
+
},
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
return report
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
@router.get(
|
| 531 |
+
"/compliance/bundle/{model_id}",
|
| 532 |
+
response_model=ComplianceBundleResponse,
|
| 533 |
+
)
|
| 534 |
+
async def get_compliance_bundle(
|
| 535 |
+
model_id: str,
|
| 536 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 537 |
+
):
|
| 538 |
+
"""
|
| 539 |
+
Get full compliance bundle for a model.
|
| 540 |
+
|
| 541 |
+
Returns complete compliance documentation including:
|
| 542 |
+
- AI Risk Passport
|
| 543 |
+
- Deployment eligibility
|
| 544 |
+
- Compliance status
|
| 545 |
+
"""
|
| 546 |
+
key = f"{ctx.tenant_id}_{model_id}"
|
| 547 |
+
|
| 548 |
+
if key not in _model_risk_data:
|
| 549 |
+
raise HTTPException(
|
| 550 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 551 |
+
detail=f"Model {model_id} not found"
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
model_data = _model_risk_data[key]
|
| 555 |
+
risk_tier = RiskTier(model_data["risk_tier"])
|
| 556 |
+
|
| 557 |
+
# Generate passport
|
| 558 |
+
generator = RiskPassportGenerator()
|
| 559 |
+
passport = generator.generate_passport(
|
| 560 |
+
model_id=model_id,
|
| 561 |
+
version=model_data.get("version", "1.0"),
|
| 562 |
+
risk_classification=risk_tier,
|
| 563 |
+
risk_score=model_data["risk_score"],
|
| 564 |
+
sector=model_data.get("sector"),
|
| 565 |
+
use_case=model_data.get("use_case"),
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
# Get deployment eligibility
|
| 569 |
+
eligibility = check_deployment_eligibility(
|
| 570 |
+
model_id=model_id,
|
| 571 |
+
risk_tier=risk_tier,
|
| 572 |
+
gss_metrics=model_data.get("gss_metrics"),
|
| 573 |
+
evaluation_complete=model_data.get("evaluation_complete", False),
|
| 574 |
+
monitoring_enabled=model_data.get("monitoring_enabled", False),
|
| 575 |
+
oversight_declared=model_data.get("oversight_declared", False),
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
return ComplianceBundleResponse(
|
| 579 |
+
model_id=model_id,
|
| 580 |
+
risk_passport=generator.to_dict(passport),
|
| 581 |
+
deployment_eligibility=eligibility.model_dump(),
|
| 582 |
+
compliance_status=model_data["compliance_status"],
|
| 583 |
+
generated_at=datetime.utcnow(),
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
# =============================================================================
|
| 588 |
+
# Compliance Status Endpoints
|
| 589 |
+
# =============================================================================
|
| 590 |
+
|
| 591 |
+
@router.get(
|
| 592 |
+
"/models/{model_id}/compliance",
|
| 593 |
+
)
|
| 594 |
+
async def get_compliance_status(
|
| 595 |
+
model_id: str,
|
| 596 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 597 |
+
):
|
| 598 |
+
"""
|
| 599 |
+
Get compliance status for a model.
|
| 600 |
+
|
| 601 |
+
Returns current compliance status and requirements.
|
| 602 |
+
"""
|
| 603 |
+
key = f"{ctx.tenant_id}_{model_id}"
|
| 604 |
+
|
| 605 |
+
if key not in _model_risk_data:
|
| 606 |
+
raise HTTPException(
|
| 607 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 608 |
+
detail=f"Model {model_id} not found"
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
model_data = _model_risk_data[key]
|
| 612 |
+
|
| 613 |
+
return {
|
| 614 |
+
"model_id": model_id,
|
| 615 |
+
"risk_tier": model_data["risk_tier"],
|
| 616 |
+
"risk_score": model_data["risk_score"],
|
| 617 |
+
"compliance_status": model_data["compliance_status"],
|
| 618 |
+
"requires_high_risk_compliance": model_data.get("requires_high_risk_compliance", False),
|
| 619 |
+
"regulatory_requirements": model_data.get("regulatory_requirements", []),
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
@router.get(
|
| 624 |
+
"/models",
|
| 625 |
+
)
|
| 626 |
+
async def list_registered_models(
|
| 627 |
+
limit: int = Query(default=10, ge=1, le=100),
|
| 628 |
+
offset: int = Query(default=0, ge=0),
|
| 629 |
+
risk_tier: Optional[str] = Query(default=None, description="Filter by risk tier"),
|
| 630 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 631 |
+
):
|
| 632 |
+
"""
|
| 633 |
+
List all registered models for the tenant.
|
| 634 |
+
|
| 635 |
+
Returns paginated list of models with their risk classifications.
|
| 636 |
+
"""
|
| 637 |
+
# Filter models for this tenant
|
| 638 |
+
tenant_models = {
|
| 639 |
+
k: v for k, v in _model_risk_data.items()
|
| 640 |
+
if k.startswith(str(ctx.tenant_id))
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
# Apply risk tier filter
|
| 644 |
+
if risk_tier:
|
| 645 |
+
tenant_models = {
|
| 646 |
+
k: v for k, v in tenant_models.items()
|
| 647 |
+
if v.get("risk_tier") == risk_tier
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
# Apply pagination
|
| 651 |
+
models_list = list(tenant_models.values())[offset:offset + limit]
|
| 652 |
+
total = len(tenant_models)
|
| 653 |
+
|
| 654 |
+
return {
|
| 655 |
+
"total": total,
|
| 656 |
+
"limit": limit,
|
| 657 |
+
"offset": offset,
|
| 658 |
+
"models": models_list,
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
# =============================================================================
|
| 663 |
+
# Utility Endpoints
|
| 664 |
+
# =============================================================================
|
| 665 |
+
|
| 666 |
+
@router.get(
|
| 667 |
+
"/health",
|
| 668 |
+
)
|
| 669 |
+
async def regulatory_health():
|
| 670 |
+
"""
|
| 671 |
+
Health check for regulatory module.
|
| 672 |
+
"""
|
| 673 |
+
return {
|
| 674 |
+
"status": "healthy",
|
| 675 |
+
"module": "regulatory",
|
| 676 |
+
"features": [
|
| 677 |
+
"risk_classification",
|
| 678 |
+
"risk_passport",
|
| 679 |
+
"deployment_eligibility",
|
| 680 |
+
"regulatory_reporting",
|
| 681 |
+
]
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
__all__ = ["router"]
|
backend/api/routes.py
ADDED
|
@@ -0,0 +1,2183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Routes
|
| 3 |
+
|
| 4 |
+
API endpoints for AegisLM evaluation framework.
|
| 5 |
+
SECURE: All endpoints require authentication and enforce tenant isolation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import List, Optional, Dict, Any
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from backend.db.models import Certificate
|
| 16 |
+
from backend.api.dependencies import get_db, get_orchestrator
|
| 17 |
+
from marketplace.plugin_submission_api import PluginSubmissionRequest
|
| 18 |
+
from federation.federated_verification_engine import FederatedScoreSubmission
|
| 19 |
+
from backend.core.config import settings
|
| 20 |
+
from backend.core.orchestrator import EvaluationOrchestrator, EvaluationInput, SampleResult, RunConfig
|
| 21 |
+
from backend.core.quota import check_job_quota, QuotaExceededError, TenantQuotaManager
|
| 22 |
+
from backend.db.models import EvaluationRun, EvaluationResult, Tenant, User, MonitoringMetric, Alert, APIKey
|
| 23 |
+
from backend.db.session import AsyncSession
|
| 24 |
+
from backend.logging.logger import get_logger
|
| 25 |
+
from backend.logging.audit import AuditLogger, AuditAction, ResourceType
|
| 26 |
+
from backend.queue.producer import get_job_producer
|
| 27 |
+
from backend.queue.status_tracker import get_status_tracker
|
| 28 |
+
from backend.queue.job_schema import JobStatusResponse, JobSubmissionRequest, JobPriority
|
| 29 |
+
from backend.queue.worker_schema import (
|
| 30 |
+
WorkerRegistrationRequest,
|
| 31 |
+
WorkerRegistrationResponse,
|
| 32 |
+
HeartbeatRequest,
|
| 33 |
+
HeartbeatResponse,
|
| 34 |
+
WorkerStatusResponse,
|
| 35 |
+
WorkerListResponse,
|
| 36 |
+
)
|
| 37 |
+
from security.permissions import (
|
| 38 |
+
AuthenticatedUser,
|
| 39 |
+
get_current_user,
|
| 40 |
+
get_tenant_context,
|
| 41 |
+
TenantContext,
|
| 42 |
+
can_create_job,
|
| 43 |
+
can_view_job,
|
| 44 |
+
can_cancel_job,
|
| 45 |
+
can_delete_job,
|
| 46 |
+
can_export_report,
|
| 47 |
+
can_manage_api_keys,
|
| 48 |
+
can_view_monitoring,
|
| 49 |
+
can_manage_users,
|
| 50 |
+
can_approve_release,
|
| 51 |
+
)
|
| 52 |
+
from security.rbac import Role, Permission
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Initialize logger
|
| 56 |
+
logger = get_logger("api", component="api")
|
| 57 |
+
|
| 58 |
+
# Create router
|
| 59 |
+
router = APIRouter(prefix="/api/v1", tags=["evaluation"])
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# =============================================================================
|
| 63 |
+
# Request/Response Models
|
| 64 |
+
# =============================================================================
|
| 65 |
+
|
| 66 |
+
class EvaluationRunRequest(BaseModel):
|
| 67 |
+
"""Request model for starting an evaluation run."""
|
| 68 |
+
|
| 69 |
+
model_name: str = Field(
|
| 70 |
+
description="Model to evaluate",
|
| 71 |
+
examples=["meta-llama/Llama-2-7b-hf"]
|
| 72 |
+
)
|
| 73 |
+
model_version: str = Field(
|
| 74 |
+
default="latest",
|
| 75 |
+
description="Model version"
|
| 76 |
+
)
|
| 77 |
+
dataset_version: str = Field(
|
| 78 |
+
description="Dataset version to use",
|
| 79 |
+
examples=["v1.0"]
|
| 80 |
+
)
|
| 81 |
+
temperature: float = Field(
|
| 82 |
+
default=0.7,
|
| 83 |
+
ge=0.0,
|
| 84 |
+
le=2.0,
|
| 85 |
+
description="Generation temperature"
|
| 86 |
+
)
|
| 87 |
+
max_tokens: int = Field(
|
| 88 |
+
default=512,
|
| 89 |
+
ge=1,
|
| 90 |
+
le=4096,
|
| 91 |
+
description="Maximum tokens to generate"
|
| 92 |
+
)
|
| 93 |
+
samples: List[SampleResult] = Field(
|
| 94 |
+
description="Samples to evaluate"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class EvaluationRunResponse(BaseModel):
|
| 99 |
+
"""Response model for evaluation run."""
|
| 100 |
+
|
| 101 |
+
run_id: uuid.UUID
|
| 102 |
+
status: str
|
| 103 |
+
model_name: str
|
| 104 |
+
model_version: str
|
| 105 |
+
dataset_version: str
|
| 106 |
+
composite_score: Optional[float] = None
|
| 107 |
+
timestamp: datetime
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class EvaluationResultResponse(BaseModel):
|
| 111 |
+
"""Response model for evaluation result."""
|
| 112 |
+
|
| 113 |
+
id: uuid.UUID
|
| 114 |
+
run_id: uuid.UUID
|
| 115 |
+
sample_id: str
|
| 116 |
+
attack_type: Optional[str] = None
|
| 117 |
+
mutation_type: Optional[str] = None
|
| 118 |
+
hallucination: Optional[float] = None
|
| 119 |
+
toxicity: Optional[float] = None
|
| 120 |
+
bias: Optional[float] = None
|
| 121 |
+
confidence: Optional[float] = None
|
| 122 |
+
robustness: Optional[float] = None
|
| 123 |
+
processing_time_ms: Optional[float] = None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class HealthResponse(BaseModel):
|
| 127 |
+
"""Response model for health check."""
|
| 128 |
+
|
| 129 |
+
status: str
|
| 130 |
+
version: str
|
| 131 |
+
db_connected: bool
|
| 132 |
+
model_loaded: bool
|
| 133 |
+
dataset_registry_valid: bool
|
| 134 |
+
policy_loaded: bool
|
| 135 |
+
weights_valid: bool
|
| 136 |
+
weights_sum: float
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class MetricsResponse(BaseModel):
|
| 140 |
+
"""Response model for system metrics."""
|
| 141 |
+
|
| 142 |
+
total_runs: int
|
| 143 |
+
completed_runs: int
|
| 144 |
+
failed_runs: int
|
| 145 |
+
average_composite_score: Optional[float] = None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# Tenant Management Models
|
| 149 |
+
class TenantCreateRequest(BaseModel):
|
| 150 |
+
"""Request model for creating a tenant."""
|
| 151 |
+
name: str = Field(description="Tenant organization name")
|
| 152 |
+
plan_type: str = Field(default="free", description="Plan type: free, basic, pro, enterprise")
|
| 153 |
+
job_quota: int = Field(default=10, ge=1, description="Maximum concurrent jobs")
|
| 154 |
+
api_rate_limit: int = Field(default=100, ge=1, description="API requests per minute")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class TenantUpdateRequest(BaseModel):
|
| 158 |
+
"""Request model for updating a tenant."""
|
| 159 |
+
name: Optional[str] = None
|
| 160 |
+
plan_type: Optional[str] = None
|
| 161 |
+
job_quota: Optional[int] = Field(default=None, ge=1)
|
| 162 |
+
api_rate_limit: Optional[int] = Field(default=None, ge=1)
|
| 163 |
+
active: Optional[bool] = None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class TenantResponse(BaseModel):
|
| 167 |
+
"""Response model for tenant."""
|
| 168 |
+
id: uuid.UUID
|
| 169 |
+
name: str
|
| 170 |
+
plan_type: str
|
| 171 |
+
job_quota: int
|
| 172 |
+
api_rate_limit: int
|
| 173 |
+
created_at: datetime
|
| 174 |
+
active: bool
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class TenantUserResponse(BaseModel):
|
| 178 |
+
"""Response model for tenant user."""
|
| 179 |
+
id: uuid.UUID
|
| 180 |
+
email: str
|
| 181 |
+
role: str
|
| 182 |
+
created_at: datetime
|
| 183 |
+
active: bool
|
| 184 |
+
last_login: Optional[datetime] = None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TenantUsageResponse(BaseModel):
|
| 188 |
+
"""Response model for tenant usage statistics."""
|
| 189 |
+
tenant_id: uuid.UUID
|
| 190 |
+
total_jobs: int
|
| 191 |
+
active_jobs: int
|
| 192 |
+
completed_jobs: int
|
| 193 |
+
total_metrics: int
|
| 194 |
+
total_alerts: int
|
| 195 |
+
total_api_keys: int
|
| 196 |
+
quota: int
|
| 197 |
+
quota_used: int
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# =============================================================================
|
| 201 |
+
# Health Endpoints
|
| 202 |
+
# =============================================================================
|
| 203 |
+
|
| 204 |
+
@router.get("/health", response_model=HealthResponse)
|
| 205 |
+
async def health_check():
|
| 206 |
+
"""
|
| 207 |
+
Enhanced health check endpoint.
|
| 208 |
+
|
| 209 |
+
Returns the health status of the service including:
|
| 210 |
+
- Database connection status
|
| 211 |
+
- Model loading status
|
| 212 |
+
- Dataset registry validity
|
| 213 |
+
- Policy loading status
|
| 214 |
+
- Weights validation (sum must equal 1.0)
|
| 215 |
+
"""
|
| 216 |
+
from backend.db.session import check_database_connection
|
| 217 |
+
from backend.core.config import settings
|
| 218 |
+
|
| 219 |
+
# Check database connection
|
| 220 |
+
db_connected = await check_database_connection()
|
| 221 |
+
|
| 222 |
+
# Check model loaded (simplified - checks if model can be loaded)
|
| 223 |
+
# In production, this would check actual model loading status
|
| 224 |
+
model_loaded = True # Default to True, model loading is handled by orchestrator
|
| 225 |
+
|
| 226 |
+
# Check dataset registry validity
|
| 227 |
+
dataset_registry_valid = True
|
| 228 |
+
try:
|
| 229 |
+
from backend.core.dataset_loader import get_dataset_loader
|
| 230 |
+
loader = get_dataset_loader()
|
| 231 |
+
datasets = loader.list_datasets()
|
| 232 |
+
dataset_registry_valid = len(datasets) >= 0 # Registry is valid if it can be loaded
|
| 233 |
+
except Exception:
|
| 234 |
+
dataset_registry_valid = False
|
| 235 |
+
|
| 236 |
+
# Check policy loaded
|
| 237 |
+
policy_loaded = True
|
| 238 |
+
try:
|
| 239 |
+
import yaml
|
| 240 |
+
from pathlib import Path
|
| 241 |
+
policy_path = Path("backend/config/policy.yaml")
|
| 242 |
+
if policy_path.exists():
|
| 243 |
+
with open(policy_path, "r") as f:
|
| 244 |
+
policy_data = yaml.safe_load(f)
|
| 245 |
+
policy_loaded = policy_data is not None
|
| 246 |
+
else:
|
| 247 |
+
policy_loaded = False
|
| 248 |
+
except Exception:
|
| 249 |
+
policy_loaded = False
|
| 250 |
+
|
| 251 |
+
# Check weights validation
|
| 252 |
+
weights_valid = True
|
| 253 |
+
weights_sum = 0.0
|
| 254 |
+
try:
|
| 255 |
+
weights_sum = (
|
| 256 |
+
settings.hallucination_weight +
|
| 257 |
+
settings.toxicity_weight +
|
| 258 |
+
settings.bias_weight +
|
| 259 |
+
settings.confidence_weight
|
| 260 |
+
)
|
| 261 |
+
weights_valid = abs(weights_sum - 1.0) < 1e-6
|
| 262 |
+
except Exception:
|
| 263 |
+
weights_valid = False
|
| 264 |
+
|
| 265 |
+
# Determine overall status
|
| 266 |
+
all_healthy = (
|
| 267 |
+
db_connected and
|
| 268 |
+
model_loaded and
|
| 269 |
+
dataset_registry_valid and
|
| 270 |
+
policy_loaded and
|
| 271 |
+
weights_valid
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
return HealthResponse(
|
| 275 |
+
status="ok" if all_healthy else "degraded",
|
| 276 |
+
version="0.1.0",
|
| 277 |
+
db_connected=db_connected,
|
| 278 |
+
model_loaded=model_loaded,
|
| 279 |
+
dataset_registry_valid=dataset_registry_valid,
|
| 280 |
+
policy_loaded=policy_loaded,
|
| 281 |
+
weights_valid=weights_valid,
|
| 282 |
+
weights_sum=weights_sum,
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# =============================================================================
|
| 287 |
+
# Evaluation Endpoints
|
| 288 |
+
# =============================================================================
|
| 289 |
+
|
| 290 |
+
@router.post(
|
| 291 |
+
"/evaluations",
|
| 292 |
+
response_model=EvaluationRunResponse,
|
| 293 |
+
status_code=status.HTTP_201_CREATED,
|
| 294 |
+
)
|
| 295 |
+
async def create_evaluation(
|
| 296 |
+
request: EvaluationRunRequest,
|
| 297 |
+
# CRITICAL FIX: Auth BEFORE DB to prevent unauthenticated DB access
|
| 298 |
+
user: AuthenticatedUser = Depends(get_current_user),
|
| 299 |
+
db: AsyncSession = Depends(get_db),
|
| 300 |
+
):
|
| 301 |
+
"""
|
| 302 |
+
Start a new evaluation run.
|
| 303 |
+
|
| 304 |
+
Creates a new evaluation run and processes all samples.
|
| 305 |
+
Requires authentication.
|
| 306 |
+
"""
|
| 307 |
+
try:
|
| 308 |
+
# Create evaluation input
|
| 309 |
+
eval_input = EvaluationInput(
|
| 310 |
+
model_name=request.model_name,
|
| 311 |
+
model_version=request.model_version,
|
| 312 |
+
dataset_name="default", # Add dataset_name as required by EvaluationInput
|
| 313 |
+
dataset_version=request.dataset_version,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
# Create orchestrator and start run
|
| 317 |
+
orchestrator = EvaluationOrchestrator()
|
| 318 |
+
|
| 319 |
+
# Run evaluation
|
| 320 |
+
output = await orchestrator.start_run(eval_input)
|
| 321 |
+
|
| 322 |
+
# Get run from database
|
| 323 |
+
from sqlalchemy import select
|
| 324 |
+
result = await db.execute(
|
| 325 |
+
select(EvaluationRun).where(EvaluationRun.id == orchestrator.state.run_id)
|
| 326 |
+
)
|
| 327 |
+
run = result.scalar_one_or_none()
|
| 328 |
+
|
| 329 |
+
if run is None:
|
| 330 |
+
raise HTTPException(
|
| 331 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 332 |
+
detail="Failed to create evaluation run"
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
return EvaluationRunResponse(
|
| 336 |
+
run_id=run.id,
|
| 337 |
+
status=run.status,
|
| 338 |
+
model_name=run.model_name,
|
| 339 |
+
model_version=run.model_version,
|
| 340 |
+
dataset_version=run.dataset_version,
|
| 341 |
+
composite_score=run.composite_score,
|
| 342 |
+
timestamp=run.timestamp,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
except Exception as e:
|
| 346 |
+
logger.error(
|
| 347 |
+
f"Failed to create evaluation: {str(e)}",
|
| 348 |
+
metadata={"error": str(e)},
|
| 349 |
+
exception=e
|
| 350 |
+
)
|
| 351 |
+
raise HTTPException(
|
| 352 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 353 |
+
detail=f"Evaluation failed: {str(e)}"
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
@router.get("/evaluations", response_model=List[EvaluationRunResponse])
|
| 358 |
+
async def list_evaluations(
|
| 359 |
+
limit: int = Query(default=10, ge=1, le=100),
|
| 360 |
+
offset: int = Query(default=0, ge=0),
|
| 361 |
+
status_filter: Optional[str] = Query(default=None),
|
| 362 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 363 |
+
db: AsyncSession = Depends(get_db),
|
| 364 |
+
):
|
| 365 |
+
"""
|
| 366 |
+
List evaluation runs for the current tenant.
|
| 367 |
+
|
| 368 |
+
Returns a paginated list of evaluation runs scoped to the authenticated user's tenant.
|
| 369 |
+
"""
|
| 370 |
+
from sqlalchemy import select, func, desc
|
| 371 |
+
|
| 372 |
+
# Build query WITH TENANT FILTERING - Critical for multi-tenant security
|
| 373 |
+
query = (
|
| 374 |
+
select(EvaluationRun)
|
| 375 |
+
.where(EvaluationRun.tenant_id == ctx.tenant_id)
|
| 376 |
+
.order_by(desc(EvaluationRun.timestamp))
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
if status_filter:
|
| 380 |
+
query = query.where(EvaluationRun.status == status_filter)
|
| 381 |
+
|
| 382 |
+
# Get total count WITH TENANT FILTERING
|
| 383 |
+
count_query = (
|
| 384 |
+
select(func.count(EvaluationRun.id))
|
| 385 |
+
.where(EvaluationRun.tenant_id == ctx.tenant_id)
|
| 386 |
+
)
|
| 387 |
+
if status_filter:
|
| 388 |
+
count_query = count_query.where(EvaluationRun.status == status_filter)
|
| 389 |
+
|
| 390 |
+
total_result = await db.execute(count_query)
|
| 391 |
+
total = total_result.scalar() or 0
|
| 392 |
+
|
| 393 |
+
# Apply pagination
|
| 394 |
+
query = query.offset(offset).limit(limit)
|
| 395 |
+
|
| 396 |
+
# Execute
|
| 397 |
+
result = await db.execute(query)
|
| 398 |
+
runs = result.scalars().all()
|
| 399 |
+
|
| 400 |
+
return [
|
| 401 |
+
EvaluationRunResponse(
|
| 402 |
+
run_id=run.id,
|
| 403 |
+
status=run.status,
|
| 404 |
+
model_name=run.model_name,
|
| 405 |
+
model_version=run.model_version,
|
| 406 |
+
dataset_version=run.dataset_version,
|
| 407 |
+
composite_score=run.composite_score,
|
| 408 |
+
timestamp=run.timestamp,
|
| 409 |
+
)
|
| 410 |
+
for run in runs
|
| 411 |
+
]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
@router.get("/evaluations/{run_id}", response_model=EvaluationRunResponse)
|
| 415 |
+
async def get_evaluation(
|
| 416 |
+
run_id: uuid.UUID,
|
| 417 |
+
# Auth BEFORE DB - ensures unauthenticated requests fail fast
|
| 418 |
+
user: AuthenticatedUser = Depends(get_current_user),
|
| 419 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 420 |
+
db: AsyncSession = Depends(get_db),
|
| 421 |
+
):
|
| 422 |
+
"""
|
| 423 |
+
Get evaluation run by ID.
|
| 424 |
+
|
| 425 |
+
Returns the evaluation run with the given ID.
|
| 426 |
+
Requires authentication and tenant scoping.
|
| 427 |
+
"""
|
| 428 |
+
from sqlalchemy import select
|
| 429 |
+
|
| 430 |
+
# Query WITH tenant filter for security
|
| 431 |
+
result = await db.execute(
|
| 432 |
+
select(EvaluationRun).where(
|
| 433 |
+
EvaluationRun.id == run_id,
|
| 434 |
+
EvaluationRun.tenant_id == ctx.tenant_id
|
| 435 |
+
)
|
| 436 |
+
)
|
| 437 |
+
run = result.scalar_one_or_none()
|
| 438 |
+
|
| 439 |
+
if run is None:
|
| 440 |
+
raise HTTPException(
|
| 441 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 442 |
+
detail=f"Evaluation run {run_id} not found"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
return EvaluationRunResponse(
|
| 446 |
+
run_id=run.id,
|
| 447 |
+
status=run.status,
|
| 448 |
+
model_name=run.model_name,
|
| 449 |
+
model_version=run.model_version,
|
| 450 |
+
dataset_version=run.dataset_version,
|
| 451 |
+
composite_score=run.composite_score,
|
| 452 |
+
timestamp=run.timestamp,
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
@router.get("/evaluations/{run_id}/results", response_model=List[EvaluationResultResponse])
|
| 457 |
+
async def get_evaluation_results(
|
| 458 |
+
run_id: uuid.UUID,
|
| 459 |
+
# Auth BEFORE DB - ensures unauthenticated requests fail fast
|
| 460 |
+
user: AuthenticatedUser = Depends(get_current_user),
|
| 461 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 462 |
+
db: AsyncSession = Depends(get_db),
|
| 463 |
+
):
|
| 464 |
+
"""
|
| 465 |
+
Get results for an evaluation run.
|
| 466 |
+
|
| 467 |
+
Returns all evaluation results for the given run.
|
| 468 |
+
Requires authentication and tenant scoping.
|
| 469 |
+
"""
|
| 470 |
+
from sqlalchemy import select
|
| 471 |
+
|
| 472 |
+
# Check run exists WITH tenant filter
|
| 473 |
+
run_result = await db.execute(
|
| 474 |
+
select(EvaluationRun).where(
|
| 475 |
+
EvaluationRun.id == run_id,
|
| 476 |
+
EvaluationRun.tenant_id == ctx.tenant_id
|
| 477 |
+
)
|
| 478 |
+
)
|
| 479 |
+
run = run_result.scalar_one_or_none()
|
| 480 |
+
|
| 481 |
+
if run is None:
|
| 482 |
+
raise HTTPException(
|
| 483 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 484 |
+
detail=f"Evaluation run {run_id} not found"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
# Get results with tenant filter
|
| 488 |
+
result = await db.execute(
|
| 489 |
+
select(EvaluationResult)
|
| 490 |
+
.where(EvaluationResult.run_id == run_id)
|
| 491 |
+
.order_by(EvaluationResult.sample_id)
|
| 492 |
+
)
|
| 493 |
+
results = result.scalars().all()
|
| 494 |
+
|
| 495 |
+
return [
|
| 496 |
+
EvaluationResultResponse(
|
| 497 |
+
id=r.id,
|
| 498 |
+
run_id=r.run_id,
|
| 499 |
+
sample_id=r.sample_id,
|
| 500 |
+
attack_type=r.attack_type,
|
| 501 |
+
mutation_type=r.mutation_type,
|
| 502 |
+
hallucination=r.hallucination,
|
| 503 |
+
toxicity=r.toxicity,
|
| 504 |
+
bias=r.bias,
|
| 505 |
+
confidence=r.confidence,
|
| 506 |
+
robustness=r.robustness,
|
| 507 |
+
processing_time_ms=r.processing_time_ms,
|
| 508 |
+
)
|
| 509 |
+
for r in results
|
| 510 |
+
]
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
@router.delete("/evaluations/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 514 |
+
async def cancel_evaluation(
|
| 515 |
+
run_id: uuid.UUID,
|
| 516 |
+
# Auth BEFORE DB - ensures unauthenticated requests fail fast
|
| 517 |
+
user: AuthenticatedUser = Depends(get_current_user),
|
| 518 |
+
ctx: TenantContext = Depends(get_tenant_context),
|
| 519 |
+
db: AsyncSession = Depends(get_db),
|
| 520 |
+
):
|
| 521 |
+
"""
|
| 522 |
+
Cancel an evaluation run.
|
| 523 |
+
|
| 524 |
+
Cancels the evaluation run with the given ID.
|
| 525 |
+
Requires authentication and tenant scoping.
|
| 526 |
+
"""
|
| 527 |
+
from sqlalchemy import select, update
|
| 528 |
+
|
| 529 |
+
# Check run exists WITH tenant filter
|
| 530 |
+
result = await db.execute(
|
| 531 |
+
select(EvaluationRun).where(
|
| 532 |
+
EvaluationRun.id == run_id,
|
| 533 |
+
EvaluationRun.tenant_id == ctx.tenant_id
|
| 534 |
+
)
|
| 535 |
+
)
|
| 536 |
+
run = result.scalar_one_or_none()
|
| 537 |
+
|
| 538 |
+
if run is None:
|
| 539 |
+
raise HTTPException(
|
| 540 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 541 |
+
detail=f"Evaluation run {run_id} not found"
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
# Update status
|
| 545 |
+
await db.execute(
|
| 546 |
+
update(EvaluationRun)
|
| 547 |
+
.where(EvaluationRun.id == run_id)
|
| 548 |
+
.values(status="cancelled")
|
| 549 |
+
)
|
| 550 |
+
await db.commit()
|
| 551 |
+
|
| 552 |
+
logger.info(
|
| 553 |
+
f"Evaluation cancelled",
|
| 554 |
+
metadata={"run_id": str(run_id), "tenant_id": str(ctx.tenant_id)}
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
# =============================================================================
|
| 559 |
+
# Metrics Endpoints
|
| 560 |
+
# =============================================================================
|
| 561 |
+
|
| 562 |
+
@router.get("/metrics", response_model=MetricsResponse)
|
| 563 |
+
async def get_metrics(
|
| 564 |
+
# Auth BEFORE DB - ensures unauthenticated requests fail fast
|
| 565 |
+
user: AuthenticatedUser = Depends(get_current_user),
|
| 566 |
+
db: AsyncSession = Depends(get_db),
|
| 567 |
+
):
|
| 568 |
+
"""
|
| 569 |
+
Get system metrics.
|
| 570 |
+
|
| 571 |
+
Returns aggregated metrics across all evaluation runs.
|
| 572 |
+
Requires authentication.
|
| 573 |
+
"""
|
| 574 |
+
from sqlalchemy import select, func
|
| 575 |
+
|
| 576 |
+
# Total runs
|
| 577 |
+
total_result = await db.execute(select(func.count(EvaluationRun.id)))
|
| 578 |
+
total_runs = total_result.scalar() or 0
|
| 579 |
+
|
| 580 |
+
# Completed runs
|
| 581 |
+
completed_result = await db.execute(
|
| 582 |
+
select(func.count(EvaluationRun.id))
|
| 583 |
+
.where(EvaluationRun.status == "completed")
|
| 584 |
+
)
|
| 585 |
+
completed_runs = completed_result.scalar() or 0
|
| 586 |
+
|
| 587 |
+
# Failed runs
|
| 588 |
+
failed_result = await db.execute(
|
| 589 |
+
select(func.count(EvaluationRun.id))
|
| 590 |
+
.where(EvaluationRun.status.in_(["failed", "cancelled"]))
|
| 591 |
+
)
|
| 592 |
+
failed_runs = failed_result.scalar() or 0
|
| 593 |
+
|
| 594 |
+
# Average composite score
|
| 595 |
+
avg_result = await db.execute(
|
| 596 |
+
select(func.avg(EvaluationRun.composite_score))
|
| 597 |
+
.where(EvaluationRun.composite_score.isnot(None))
|
| 598 |
+
)
|
| 599 |
+
average_composite_score = avg_result.scalar()
|
| 600 |
+
|
| 601 |
+
return MetricsResponse(
|
| 602 |
+
total_runs=total_runs,
|
| 603 |
+
completed_runs=completed_runs,
|
| 604 |
+
failed_runs=failed_runs,
|
| 605 |
+
average_composite_score=average_composite_score,
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
# =============================================================================
|
| 610 |
+
# Model Endpoints
|
| 611 |
+
# =============================================================================
|
| 612 |
+
|
| 613 |
+
@router.get("/models")
|
| 614 |
+
async def list_models():
|
| 615 |
+
"""
|
| 616 |
+
List available models.
|
| 617 |
+
|
| 618 |
+
Returns a list of available models for evaluation.
|
| 619 |
+
"""
|
| 620 |
+
# TODO: Implement actual model listing from registry
|
| 621 |
+
return [
|
| 622 |
+
{
|
| 623 |
+
"name": settings.default_model,
|
| 624 |
+
"version": "latest",
|
| 625 |
+
"default": True,
|
| 626 |
+
}
|
| 627 |
+
]
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
@router.get("/datasets")
|
| 631 |
+
async def list_datasets():
|
| 632 |
+
"""
|
| 633 |
+
List available datasets.
|
| 634 |
+
|
| 635 |
+
Returns a list of available datasets.
|
| 636 |
+
"""
|
| 637 |
+
# TODO: Implement actual dataset listing
|
| 638 |
+
return [
|
| 639 |
+
{
|
| 640 |
+
"name": "aegislm-harmful-queries",
|
| 641 |
+
"version": "v1.0",
|
| 642 |
+
"num_samples": 1000,
|
| 643 |
+
}
|
| 644 |
+
]
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
# =============================================================================
|
| 648 |
+
# Job Queue Endpoints (Week 6 Day 1 - Enterprise Hardening)
|
| 649 |
+
# =============================================================================
|
| 650 |
+
|
| 651 |
+
@router.post(
|
| 652 |
+
"/jobs",
|
| 653 |
+
response_model=JobStatusResponse,
|
| 654 |
+
status_code=status.HTTP_201_CREATED,
|
| 655 |
+
)
|
| 656 |
+
async def submit_job(
|
| 657 |
+
request: JobSubmissionRequest,
|
| 658 |
+
):
|
| 659 |
+
"""
|
| 660 |
+
Submit a new evaluation job to the queue.
|
| 661 |
+
|
| 662 |
+
Creates a new job in the queue for asynchronous processing.
|
| 663 |
+
"""
|
| 664 |
+
try:
|
| 665 |
+
producer = get_job_producer()
|
| 666 |
+
job = await producer.submit_job(request)
|
| 667 |
+
|
| 668 |
+
return JobStatusResponse(
|
| 669 |
+
job_id=job.job_id,
|
| 670 |
+
job_type=job.job_type,
|
| 671 |
+
status=job.status,
|
| 672 |
+
progress=job.progress,
|
| 673 |
+
total_samples=job.total_samples,
|
| 674 |
+
completed_samples=job.completed_samples,
|
| 675 |
+
failed_samples=job.failed_samples,
|
| 676 |
+
composite_score=job.composite_score,
|
| 677 |
+
metrics=job.metrics,
|
| 678 |
+
error=job.error,
|
| 679 |
+
created_at=job.created_at,
|
| 680 |
+
started_at=job.started_at,
|
| 681 |
+
completed_at=job.completed_at,
|
| 682 |
+
worker_id=job.worker_id,
|
| 683 |
+
)
|
| 684 |
+
except Exception as e:
|
| 685 |
+
logger.error(
|
| 686 |
+
f"Failed to submit job: {str(e)}",
|
| 687 |
+
metadata={"error": str(e)},
|
| 688 |
+
exception=e
|
| 689 |
+
)
|
| 690 |
+
raise HTTPException(
|
| 691 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 692 |
+
detail=f"Job submission failed: {str(e)}"
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
@router.get("/job/{job_id}/status", response_model=JobStatusResponse)
|
| 697 |
+
async def get_job_status(
|
| 698 |
+
job_id: uuid.UUID,
|
| 699 |
+
):
|
| 700 |
+
"""
|
| 701 |
+
Get job status.
|
| 702 |
+
|
| 703 |
+
Returns the status of a job in the queue.
|
| 704 |
+
"""
|
| 705 |
+
try:
|
| 706 |
+
status_tracker = get_status_tracker()
|
| 707 |
+
job_status = await status_tracker.get_job_status(job_id)
|
| 708 |
+
|
| 709 |
+
if job_status is None:
|
| 710 |
+
raise HTTPException(
|
| 711 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 712 |
+
detail=f"Job {job_id} not found"
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
return job_status
|
| 716 |
+
except HTTPException:
|
| 717 |
+
raise
|
| 718 |
+
except Exception as e:
|
| 719 |
+
logger.error(
|
| 720 |
+
f"Failed to get job status: {str(e)}",
|
| 721 |
+
job_id=str(job_id),
|
| 722 |
+
error=str(e),
|
| 723 |
+
)
|
| 724 |
+
raise HTTPException(
|
| 725 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 726 |
+
detail=f"Failed to get job status: {str(e)}"
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
@router.get("/jobs", response_model=List[JobStatusResponse])
|
| 731 |
+
async def list_jobs(
|
| 732 |
+
limit: int = Query(default=10, ge=1, le=100),
|
| 733 |
+
offset: int = Query(default=0, ge=0),
|
| 734 |
+
status_filter: Optional[str] = Query(default=None),
|
| 735 |
+
):
|
| 736 |
+
"""
|
| 737 |
+
List jobs in the queue.
|
| 738 |
+
|
| 739 |
+
Returns a paginated list of jobs.
|
| 740 |
+
"""
|
| 741 |
+
from backend.queue.producer import _job_queue
|
| 742 |
+
|
| 743 |
+
jobs = list(_job_queue)
|
| 744 |
+
|
| 745 |
+
# Apply status filter
|
| 746 |
+
if status_filter:
|
| 747 |
+
jobs = [j for j in jobs if j.status.value == status_filter]
|
| 748 |
+
|
| 749 |
+
# Apply pagination
|
| 750 |
+
total = len(jobs)
|
| 751 |
+
jobs = jobs[offset:offset + limit]
|
| 752 |
+
|
| 753 |
+
return [
|
| 754 |
+
JobStatusResponse(
|
| 755 |
+
job_id=job.job_id,
|
| 756 |
+
job_type=job.job_type,
|
| 757 |
+
status=job.status,
|
| 758 |
+
progress=job.progress,
|
| 759 |
+
total_samples=job.total_samples,
|
| 760 |
+
completed_samples=job.completed_samples,
|
| 761 |
+
failed_samples=job.failed_samples,
|
| 762 |
+
composite_score=job.composite_score,
|
| 763 |
+
metrics=job.metrics,
|
| 764 |
+
error=job.error,
|
| 765 |
+
created_at=job.created_at,
|
| 766 |
+
started_at=job.started_at,
|
| 767 |
+
completed_at=job.completed_at,
|
| 768 |
+
worker_id=job.worker_id,
|
| 769 |
+
)
|
| 770 |
+
for job in jobs
|
| 771 |
+
]
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 775 |
+
async def cancel_job(
|
| 776 |
+
job_id: uuid.UUID,
|
| 777 |
+
):
|
| 778 |
+
"""
|
| 779 |
+
Cancel a job.
|
| 780 |
+
|
| 781 |
+
Cancels a pending or running job.
|
| 782 |
+
"""
|
| 783 |
+
try:
|
| 784 |
+
producer = get_job_producer()
|
| 785 |
+
success = await producer.cancel_job(job_id)
|
| 786 |
+
|
| 787 |
+
if not success:
|
| 788 |
+
raise HTTPException(
|
| 789 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 790 |
+
detail=f"Job {job_id} not found or already completed"
|
| 791 |
+
)
|
| 792 |
+
|
| 793 |
+
logger.info(
|
| 794 |
+
"Job cancelled via API",
|
| 795 |
+
job_id=str(job_id),
|
| 796 |
+
)
|
| 797 |
+
except HTTPException:
|
| 798 |
+
raise
|
| 799 |
+
except Exception as e:
|
| 800 |
+
logger.error(
|
| 801 |
+
f"Failed to cancel job: {str(e)}",
|
| 802 |
+
job_id=str(job_id),
|
| 803 |
+
error=str(e),
|
| 804 |
+
)
|
| 805 |
+
raise HTTPException(
|
| 806 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 807 |
+
detail=f"Failed to cancel job: {str(e)}"
|
| 808 |
+
)
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
# =============================================================================
|
| 812 |
+
# Worker Management Endpoints (Week 6 Day 2 - Distributed Worker Coordination)
|
| 813 |
+
# =============================================================================
|
| 814 |
+
|
| 815 |
+
@router.post(
|
| 816 |
+
"/workers/register",
|
| 817 |
+
response_model=WorkerRegistrationResponse,
|
| 818 |
+
status_code=status.HTTP_201_CREATED,
|
| 819 |
+
)
|
| 820 |
+
async def register_worker(
|
| 821 |
+
request: WorkerRegistrationRequest,
|
| 822 |
+
):
|
| 823 |
+
"""
|
| 824 |
+
Register a new worker in the cluster.
|
| 825 |
+
|
| 826 |
+
Workers must register before they can receive jobs.
|
| 827 |
+
Returns a worker_id that must be used in subsequent API calls.
|
| 828 |
+
"""
|
| 829 |
+
try:
|
| 830 |
+
from backend.queue.worker_registry import get_worker_registry
|
| 831 |
+
|
| 832 |
+
registry = get_worker_registry()
|
| 833 |
+
response = await registry.register_worker(request)
|
| 834 |
+
|
| 835 |
+
logger.info(
|
| 836 |
+
"Worker registered via API",
|
| 837 |
+
worker_id=response.worker_id,
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
return response
|
| 841 |
+
except Exception as e:
|
| 842 |
+
logger.error(
|
| 843 |
+
f"Failed to register worker: {str(e)}",
|
| 844 |
+
error=str(e),
|
| 845 |
+
)
|
| 846 |
+
raise HTTPException(
|
| 847 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 848 |
+
detail=f"Worker registration failed: {str(e)}"
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
|
| 852 |
+
@router.post(
|
| 853 |
+
"/workers/heartbeat",
|
| 854 |
+
response_model=HeartbeatResponse,
|
| 855 |
+
)
|
| 856 |
+
async def worker_heartbeat(
|
| 857 |
+
request: HeartbeatRequest,
|
| 858 |
+
):
|
| 859 |
+
"""
|
| 860 |
+
Worker heartbeat endpoint.
|
| 861 |
+
|
| 862 |
+
Workers should send heartbeats every 30 seconds to indicate they're alive.
|
| 863 |
+
Updates worker status, GPU usage, and active job count.
|
| 864 |
+
"""
|
| 865 |
+
try:
|
| 866 |
+
from backend.queue.worker_registry import get_worker_registry
|
| 867 |
+
|
| 868 |
+
registry = get_worker_registry()
|
| 869 |
+
response = await registry.heartbeat(request)
|
| 870 |
+
|
| 871 |
+
return response
|
| 872 |
+
except ValueError as e:
|
| 873 |
+
raise HTTPException(
|
| 874 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 875 |
+
detail=str(e)
|
| 876 |
+
)
|
| 877 |
+
except Exception as e:
|
| 878 |
+
logger.error(
|
| 879 |
+
f"Failed to process heartbeat: {str(e)}",
|
| 880 |
+
worker_id=request.worker_id,
|
| 881 |
+
error=str(e),
|
| 882 |
+
)
|
| 883 |
+
raise HTTPException(
|
| 884 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 885 |
+
detail=f"Heartbeat failed: {str(e)}"
|
| 886 |
+
)
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
@router.get("/workers/{worker_id}/status", response_model=WorkerStatusResponse)
|
| 890 |
+
async def get_worker_status(
|
| 891 |
+
worker_id: str,
|
| 892 |
+
):
|
| 893 |
+
"""
|
| 894 |
+
Get worker status by ID.
|
| 895 |
+
|
| 896 |
+
Returns detailed status information about a specific worker.
|
| 897 |
+
"""
|
| 898 |
+
try:
|
| 899 |
+
from backend.queue.worker_registry import get_worker_registry
|
| 900 |
+
|
| 901 |
+
registry = get_worker_registry()
|
| 902 |
+
worker_status = await registry.get_worker_status(worker_id)
|
| 903 |
+
|
| 904 |
+
if worker_status is None:
|
| 905 |
+
raise HTTPException(
|
| 906 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 907 |
+
detail=f"Worker {worker_id} not found"
|
| 908 |
+
)
|
| 909 |
+
|
| 910 |
+
return worker_status
|
| 911 |
+
except HTTPException:
|
| 912 |
+
raise
|
| 913 |
+
except Exception as e:
|
| 914 |
+
logger.error(
|
| 915 |
+
f"Failed to get worker status: {str(e)}",
|
| 916 |
+
worker_id=worker_id,
|
| 917 |
+
error=str(e),
|
| 918 |
+
)
|
| 919 |
+
raise HTTPException(
|
| 920 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 921 |
+
detail=f"Failed to get worker status: {str(e)}"
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
@router.get("/workers", response_model=WorkerListResponse)
|
| 926 |
+
async def list_workers(
|
| 927 |
+
status_filter: Optional[str] = Query(default=None),
|
| 928 |
+
):
|
| 929 |
+
"""
|
| 930 |
+
List all workers in the cluster.
|
| 931 |
+
|
| 932 |
+
Returns a list of all registered workers with their status.
|
| 933 |
+
"""
|
| 934 |
+
try:
|
| 935 |
+
from backend.queue.worker_registry import get_worker_registry
|
| 936 |
+
from backend.queue.worker_schema import WorkerStatus
|
| 937 |
+
|
| 938 |
+
registry = get_worker_registry()
|
| 939 |
+
|
| 940 |
+
# Parse status filter
|
| 941 |
+
worker_status_filter = None
|
| 942 |
+
if status_filter:
|
| 943 |
+
try:
|
| 944 |
+
worker_status_filter = WorkerStatus(status_filter)
|
| 945 |
+
except ValueError:
|
| 946 |
+
pass
|
| 947 |
+
|
| 948 |
+
response = await registry.list_workers(status_filter=worker_status_filter)
|
| 949 |
+
|
| 950 |
+
return response
|
| 951 |
+
except Exception as e:
|
| 952 |
+
logger.error(
|
| 953 |
+
f"Failed to list workers: {str(e)}",
|
| 954 |
+
error=str(e),
|
| 955 |
+
)
|
| 956 |
+
raise HTTPException(
|
| 957 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 958 |
+
detail=f"Failed to list workers: {str(e)}"
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
# =============================================================================
|
| 963 |
+
# Audit Verification Endpoints (Week 6 Day 4 - Enterprise Security Hardening)
|
| 964 |
+
# =============================================================================
|
| 965 |
+
|
| 966 |
+
class AuditVerificationResponse(BaseModel):
|
| 967 |
+
"""Response model for audit verification."""
|
| 968 |
+
|
| 969 |
+
tenant_id: uuid.UUID
|
| 970 |
+
total_entries: int
|
| 971 |
+
chain_valid: bool
|
| 972 |
+
issues: List[Dict[str, Any]]
|
| 973 |
+
first_entry_timestamp: Optional[str] = None
|
| 974 |
+
last_entry_timestamp: Optional[str] = None
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
@router.get("/admin/audit/verify", response_model=AuditVerificationResponse)
|
| 978 |
+
async def verify_audit_chain(
|
| 979 |
+
tenant_id: uuid.UUID,
|
| 980 |
+
db: AsyncSession = Depends(get_db),
|
| 981 |
+
):
|
| 982 |
+
"""
|
| 983 |
+
Verify audit log integrity (admin endpoint).
|
| 984 |
+
|
| 985 |
+
Recomputes hash chain and checks for tampering.
|
| 986 |
+
If mismatch → integrity breach detected.
|
| 987 |
+
|
| 988 |
+
This is critical for compliance and security monitoring.
|
| 989 |
+
"""
|
| 990 |
+
from backend.logging.audit_hash_chain import AuditHashChain
|
| 991 |
+
|
| 992 |
+
try:
|
| 993 |
+
chain_status = await AuditHashChain.get_chain_status(db, tenant_id)
|
| 994 |
+
|
| 995 |
+
return AuditVerificationResponse(
|
| 996 |
+
tenant_id=tenant_id,
|
| 997 |
+
total_entries=chain_status["total_entries"],
|
| 998 |
+
chain_valid=chain_status["chain_valid"],
|
| 999 |
+
issues=chain_status["issues"],
|
| 1000 |
+
first_entry_timestamp=chain_status.get("first_entry_timestamp"),
|
| 1001 |
+
last_entry_timestamp=chain_status.get("last_entry_timestamp"),
|
| 1002 |
+
)
|
| 1003 |
+
except Exception as e:
|
| 1004 |
+
logger.error(
|
| 1005 |
+
f"Failed to verify audit chain: {str(e)}",
|
| 1006 |
+
tenant_id=str(tenant_id),
|
| 1007 |
+
error=str(e),
|
| 1008 |
+
exception=e,
|
| 1009 |
+
)
|
| 1010 |
+
raise HTTPException(
|
| 1011 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1012 |
+
detail=f"Audit verification failed: {str(e)}"
|
| 1013 |
+
)
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
@router.delete("/tenants/{tenant_id}/data", status_code=status.HTTP_204_NO_CONTENT)
|
| 1017 |
+
async def purge_tenant_data(
|
| 1018 |
+
tenant_id: uuid.UUID,
|
| 1019 |
+
db: AsyncSession = Depends(get_db),
|
| 1020 |
+
):
|
| 1021 |
+
"""
|
| 1022 |
+
Purge all data for a tenant (GDPR-like deletion).
|
| 1023 |
+
|
| 1024 |
+
This is required by compliance frameworks like GDPR.
|
| 1025 |
+
All tenant data is permanently deleted.
|
| 1026 |
+
"""
|
| 1027 |
+
from sqlalchemy import delete
|
| 1028 |
+
|
| 1029 |
+
# Delete all tenant data
|
| 1030 |
+
await db.execute(delete(EvaluationRun).where(EvaluationRun.tenant_id == tenant_id))
|
| 1031 |
+
# Note: EvaluationResults are deleted via cascade
|
| 1032 |
+
await db.commit()
|
| 1033 |
+
|
| 1034 |
+
logger.warning(f"Tenant data purged", tenant_id=str(tenant_id))
|
| 1035 |
+
|
| 1036 |
+
return None
|
| 1037 |
+
try:
|
| 1038 |
+
from backend.queue.worker_registry import get_worker_registry
|
| 1039 |
+
|
| 1040 |
+
registry = get_worker_registry()
|
| 1041 |
+
metrics = await registry.get_worker_metrics()
|
| 1042 |
+
|
| 1043 |
+
return metrics
|
| 1044 |
+
except Exception as e:
|
| 1045 |
+
logger.error(
|
| 1046 |
+
f"Failed to get worker metrics: {str(e)}",
|
| 1047 |
+
error=str(e),
|
| 1048 |
+
)
|
| 1049 |
+
raise HTTPException(
|
| 1050 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1051 |
+
detail=f"Failed to get worker metrics: {str(e)}"
|
| 1052 |
+
)
|
| 1053 |
+
|
| 1054 |
+
|
| 1055 |
+
# =============================================================================
|
| 1056 |
+
# Tenant Management Endpoints (Week 6 Day 3 - Multi-Tenant)
|
| 1057 |
+
# =============================================================================
|
| 1058 |
+
|
| 1059 |
+
@router.post(
|
| 1060 |
+
"/tenants",
|
| 1061 |
+
response_model=TenantResponse,
|
| 1062 |
+
status_code=status.HTTP_201_CREATED,
|
| 1063 |
+
)
|
| 1064 |
+
async def create_tenant(
|
| 1065 |
+
request: TenantCreateRequest,
|
| 1066 |
+
db: AsyncSession = Depends(get_db),
|
| 1067 |
+
):
|
| 1068 |
+
"""
|
| 1069 |
+
Create a new tenant.
|
| 1070 |
+
|
| 1071 |
+
Only platform admins can create new tenants.
|
| 1072 |
+
"""
|
| 1073 |
+
# Create new tenant
|
| 1074 |
+
tenant = Tenant(
|
| 1075 |
+
name=request.name,
|
| 1076 |
+
plan_type=request.plan_type,
|
| 1077 |
+
job_quota=request.job_quota,
|
| 1078 |
+
api_rate_limit=request.api_rate_limit,
|
| 1079 |
+
active=True,
|
| 1080 |
+
)
|
| 1081 |
+
|
| 1082 |
+
db.add(tenant)
|
| 1083 |
+
await db.commit()
|
| 1084 |
+
await db.refresh(tenant)
|
| 1085 |
+
|
| 1086 |
+
logger.info(
|
| 1087 |
+
"Tenant created via API",
|
| 1088 |
+
tenant_id=str(tenant.id),
|
| 1089 |
+
tenant_name=tenant.name,
|
| 1090 |
+
)
|
| 1091 |
+
|
| 1092 |
+
return TenantResponse(
|
| 1093 |
+
id=tenant.id,
|
| 1094 |
+
name=tenant.name,
|
| 1095 |
+
plan_type=tenant.plan_type,
|
| 1096 |
+
job_quota=tenant.job_quota,
|
| 1097 |
+
api_rate_limit=tenant.api_rate_limit,
|
| 1098 |
+
created_at=tenant.created_at,
|
| 1099 |
+
active=tenant.active,
|
| 1100 |
+
)
|
| 1101 |
+
|
| 1102 |
+
|
| 1103 |
+
@router.get("/tenants", response_model=List[TenantResponse])
|
| 1104 |
+
async def list_tenants(
|
| 1105 |
+
limit: int = Query(default=10, ge=1, le=100),
|
| 1106 |
+
offset: int = Query(default=0, ge=0),
|
| 1107 |
+
db: AsyncSession = Depends(get_db),
|
| 1108 |
+
):
|
| 1109 |
+
"""
|
| 1110 |
+
List all tenants.
|
| 1111 |
+
|
| 1112 |
+
Returns a paginated list of tenants.
|
| 1113 |
+
"""
|
| 1114 |
+
from sqlalchemy import select, func, desc
|
| 1115 |
+
|
| 1116 |
+
# Build query
|
| 1117 |
+
query = select(Tenant).order_by(desc(Tenant.created_at))
|
| 1118 |
+
|
| 1119 |
+
# Get total count
|
| 1120 |
+
count_query = select(func.count(Tenant.id))
|
| 1121 |
+
total_result = await db.execute(count_query)
|
| 1122 |
+
total = total_result.scalar() or 0
|
| 1123 |
+
|
| 1124 |
+
# Apply pagination
|
| 1125 |
+
query = query.offset(offset).limit(limit)
|
| 1126 |
+
|
| 1127 |
+
# Execute
|
| 1128 |
+
result = await db.execute(query)
|
| 1129 |
+
tenants = result.scalars().all()
|
| 1130 |
+
|
| 1131 |
+
return [
|
| 1132 |
+
TenantResponse(
|
| 1133 |
+
id=t.id,
|
| 1134 |
+
name=t.name,
|
| 1135 |
+
plan_type=t.plan_type,
|
| 1136 |
+
job_quota=t.job_quota,
|
| 1137 |
+
api_rate_limit=t.api_rate_limit,
|
| 1138 |
+
created_at=t.created_at,
|
| 1139 |
+
active=t.active,
|
| 1140 |
+
)
|
| 1141 |
+
for t in tenants
|
| 1142 |
+
]
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
@router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
| 1146 |
+
async def get_tenant(
|
| 1147 |
+
tenant_id: uuid.UUID,
|
| 1148 |
+
db: AsyncSession = Depends(get_db),
|
| 1149 |
+
):
|
| 1150 |
+
"""
|
| 1151 |
+
Get tenant by ID.
|
| 1152 |
+
|
| 1153 |
+
Returns the tenant with the given ID.
|
| 1154 |
+
"""
|
| 1155 |
+
from sqlalchemy import select
|
| 1156 |
+
|
| 1157 |
+
result = await db.execute(
|
| 1158 |
+
select(Tenant).where(Tenant.id == tenant_id)
|
| 1159 |
+
)
|
| 1160 |
+
tenant = result.scalar_one_or_none()
|
| 1161 |
+
|
| 1162 |
+
if tenant is None:
|
| 1163 |
+
raise HTTPException(
|
| 1164 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1165 |
+
detail=f"Tenant {tenant_id} not found"
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
return TenantResponse(
|
| 1169 |
+
id=tenant.id,
|
| 1170 |
+
name=tenant.name,
|
| 1171 |
+
plan_type=tenant.plan_type,
|
| 1172 |
+
job_quota=tenant.job_quota,
|
| 1173 |
+
api_rate_limit=tenant.api_rate_limit,
|
| 1174 |
+
created_at=tenant.created_at,
|
| 1175 |
+
active=tenant.active,
|
| 1176 |
+
)
|
| 1177 |
+
|
| 1178 |
+
|
| 1179 |
+
@router.put("/tenants/{tenant_id}", response_model=TenantResponse)
|
| 1180 |
+
async def update_tenant(
|
| 1181 |
+
tenant_id: uuid.UUID,
|
| 1182 |
+
request: TenantUpdateRequest,
|
| 1183 |
+
db: AsyncSession = Depends(get_db),
|
| 1184 |
+
):
|
| 1185 |
+
"""
|
| 1186 |
+
Update tenant.
|
| 1187 |
+
|
| 1188 |
+
Updates tenant configuration (plan, quotas, etc).
|
| 1189 |
+
"""
|
| 1190 |
+
from sqlalchemy import select
|
| 1191 |
+
|
| 1192 |
+
# Check tenant exists
|
| 1193 |
+
result = await db.execute(
|
| 1194 |
+
select(Tenant).where(Tenant.id == tenant_id)
|
| 1195 |
+
)
|
| 1196 |
+
tenant = result.scalar_one_or_none()
|
| 1197 |
+
|
| 1198 |
+
if tenant is None:
|
| 1199 |
+
raise HTTPException(
|
| 1200 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1201 |
+
detail=f"Tenant {tenant_id} not found"
|
| 1202 |
+
)
|
| 1203 |
+
|
| 1204 |
+
# Update fields
|
| 1205 |
+
update_data = request.dict(exclude_unset=True)
|
| 1206 |
+
for field, value in update_data.items():
|
| 1207 |
+
setattr(tenant, field, value)
|
| 1208 |
+
|
| 1209 |
+
await db.commit()
|
| 1210 |
+
await db.refresh(tenant)
|
| 1211 |
+
|
| 1212 |
+
logger.info(
|
| 1213 |
+
"Tenant updated via API",
|
| 1214 |
+
tenant_id=str(tenant.id),
|
| 1215 |
+
)
|
| 1216 |
+
|
| 1217 |
+
return TenantResponse(
|
| 1218 |
+
id=tenant.id,
|
| 1219 |
+
name=tenant.name,
|
| 1220 |
+
plan_type=tenant.plan_type,
|
| 1221 |
+
job_quota=tenant.job_quota,
|
| 1222 |
+
api_rate_limit=tenant.api_rate_limit,
|
| 1223 |
+
created_at=tenant.created_at,
|
| 1224 |
+
active=tenant.active,
|
| 1225 |
+
)
|
| 1226 |
+
|
| 1227 |
+
|
| 1228 |
+
@router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 1229 |
+
async def deactivate_tenant(
|
| 1230 |
+
tenant_id: uuid.UUID,
|
| 1231 |
+
db: AsyncSession = Depends(get_db),
|
| 1232 |
+
):
|
| 1233 |
+
"""
|
| 1234 |
+
Deactivate a tenant.
|
| 1235 |
+
|
| 1236 |
+
Deactivates a tenant (soft delete). All tenant data is preserved
|
| 1237 |
+
but the tenant cannot create new jobs or access the system.
|
| 1238 |
+
"""
|
| 1239 |
+
from sqlalchemy import select
|
| 1240 |
+
|
| 1241 |
+
# Check tenant exists
|
| 1242 |
+
result = await db.execute(
|
| 1243 |
+
select(Tenant).where(Tenant.id == tenant_id)
|
| 1244 |
+
)
|
| 1245 |
+
tenant = result.scalar_one_or_none()
|
| 1246 |
+
|
| 1247 |
+
if tenant is None:
|
| 1248 |
+
raise HTTPException(
|
| 1249 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1250 |
+
detail=f"Tenant {tenant_id} not found"
|
| 1251 |
+
)
|
| 1252 |
+
|
| 1253 |
+
# Deactivate tenant
|
| 1254 |
+
tenant.active = False
|
| 1255 |
+
await db.commit()
|
| 1256 |
+
|
| 1257 |
+
logger.info(
|
| 1258 |
+
"Tenant deactivated via API",
|
| 1259 |
+
tenant_id=str(tenant_id),
|
| 1260 |
+
)
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
@router.get("/tenants/{tenant_id}/users", response_model=List[TenantUserResponse])
|
| 1264 |
+
async def list_tenant_users(
|
| 1265 |
+
tenant_id: uuid.UUID,
|
| 1266 |
+
limit: int = Query(default=10, ge=1, le=100),
|
| 1267 |
+
offset: int = Query(default=0, ge=0),
|
| 1268 |
+
db: AsyncSession = Depends(get_db),
|
| 1269 |
+
):
|
| 1270 |
+
"""
|
| 1271 |
+
List users in a tenant.
|
| 1272 |
+
|
| 1273 |
+
Returns all users belonging to the specified tenant.
|
| 1274 |
+
"""
|
| 1275 |
+
from sqlalchemy import select, desc
|
| 1276 |
+
|
| 1277 |
+
# Check tenant exists
|
| 1278 |
+
result = await db.execute(
|
| 1279 |
+
select(Tenant).where(Tenant.id == tenant_id)
|
| 1280 |
+
)
|
| 1281 |
+
tenant = result.scalar_one_or_none()
|
| 1282 |
+
|
| 1283 |
+
if tenant is None:
|
| 1284 |
+
raise HTTPException(
|
| 1285 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1286 |
+
detail=f"Tenant {tenant_id} not found"
|
| 1287 |
+
)
|
| 1288 |
+
|
| 1289 |
+
# Get users
|
| 1290 |
+
query = (
|
| 1291 |
+
select(User)
|
| 1292 |
+
.where(User.tenant_id == tenant_id)
|
| 1293 |
+
.order_by(desc(User.created_at))
|
| 1294 |
+
.offset(offset)
|
| 1295 |
+
.limit(limit)
|
| 1296 |
+
)
|
| 1297 |
+
|
| 1298 |
+
result = await db.execute(query)
|
| 1299 |
+
users = result.scalars().all()
|
| 1300 |
+
|
| 1301 |
+
return [
|
| 1302 |
+
TenantUserResponse(
|
| 1303 |
+
id=u.id,
|
| 1304 |
+
email=u.email,
|
| 1305 |
+
role=u.role,
|
| 1306 |
+
created_at=u.created_at,
|
| 1307 |
+
active=u.active,
|
| 1308 |
+
last_login=u.last_login,
|
| 1309 |
+
)
|
| 1310 |
+
for u in users
|
| 1311 |
+
]
|
| 1312 |
+
|
| 1313 |
+
|
| 1314 |
+
@router.get("/tenants/{tenant_id}/usage", response_model=TenantUsageResponse)
|
| 1315 |
+
async def get_tenant_usage(
|
| 1316 |
+
tenant_id: uuid.UUID,
|
| 1317 |
+
db: AsyncSession = Depends(get_db),
|
| 1318 |
+
):
|
| 1319 |
+
"""
|
| 1320 |
+
Get tenant usage statistics.
|
| 1321 |
+
|
| 1322 |
+
Returns usage statistics for the specified tenant including:
|
| 1323 |
+
- Total jobs run
|
| 1324 |
+
- Active jobs
|
| 1325 |
+
- API requests
|
| 1326 |
+
- Storage usage
|
| 1327 |
+
"""
|
| 1328 |
+
from sqlalchemy import select, func
|
| 1329 |
+
|
| 1330 |
+
# Check tenant exists
|
| 1331 |
+
result = await db.execute(
|
| 1332 |
+
select(Tenant).where(Tenant.id == tenant_id)
|
| 1333 |
+
)
|
| 1334 |
+
tenant = result.scalar_one_or_none()
|
| 1335 |
+
|
| 1336 |
+
if tenant is None:
|
| 1337 |
+
raise HTTPException(
|
| 1338 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1339 |
+
detail=f"Tenant {tenant_id} not found"
|
| 1340 |
+
)
|
| 1341 |
+
|
| 1342 |
+
# Get job counts
|
| 1343 |
+
total_jobs_result = await db.execute(
|
| 1344 |
+
select(func.count(EvaluationRun.id)).where(EvaluationRun.tenant_id == tenant_id)
|
| 1345 |
+
)
|
| 1346 |
+
total_jobs = total_jobs_result.scalar() or 0
|
| 1347 |
+
|
| 1348 |
+
active_jobs_result = await db.execute(
|
| 1349 |
+
select(func.count(EvaluationRun.id)).where(
|
| 1350 |
+
EvaluationRun.tenant_id == tenant_id,
|
| 1351 |
+
EvaluationRun.status.in_(["pending", "running"])
|
| 1352 |
+
)
|
| 1353 |
+
)
|
| 1354 |
+
active_jobs = active_jobs_result.scalar() or 0
|
| 1355 |
+
|
| 1356 |
+
completed_jobs_result = await db.execute(
|
| 1357 |
+
select(func.count(EvaluationRun.id)).where(
|
| 1358 |
+
EvaluationRun.tenant_id == tenant_id,
|
| 1359 |
+
EvaluationRun.status == "completed"
|
| 1360 |
+
)
|
| 1361 |
+
)
|
| 1362 |
+
completed_jobs = completed_jobs_result.scalar() or 0
|
| 1363 |
+
|
| 1364 |
+
# Get monitoring metrics count
|
| 1365 |
+
metrics_result = await db.execute(
|
| 1366 |
+
select(func.count(MonitoringMetric.id)).where(MonitoringMetric.tenant_id == tenant_id)
|
| 1367 |
+
)
|
| 1368 |
+
total_metrics = metrics_result.scalar() or 0
|
| 1369 |
+
|
| 1370 |
+
# Get alerts count
|
| 1371 |
+
alerts_result = await db.execute(
|
| 1372 |
+
select(func.count(Alert.id)).where(Alert.tenant_id == tenant_id)
|
| 1373 |
+
)
|
| 1374 |
+
total_alerts = alerts_result.scalar() or 0
|
| 1375 |
+
|
| 1376 |
+
# Get API keys count
|
| 1377 |
+
api_keys_result = await db.execute(
|
| 1378 |
+
select(func.count(APIKey.id)).where(APIKey.tenant_id == tenant_id)
|
| 1379 |
+
)
|
| 1380 |
+
total_api_keys = api_keys_result.scalar() or 0
|
| 1381 |
+
|
| 1382 |
+
return TenantUsageResponse(
|
| 1383 |
+
tenant_id=tenant_id,
|
| 1384 |
+
total_jobs=total_jobs,
|
| 1385 |
+
active_jobs=active_jobs,
|
| 1386 |
+
completed_jobs=completed_jobs,
|
| 1387 |
+
total_metrics=total_metrics,
|
| 1388 |
+
total_alerts=total_alerts,
|
| 1389 |
+
total_api_keys=total_api_keys,
|
| 1390 |
+
quota=tenant.job_quota,
|
| 1391 |
+
quota_used=active_jobs,
|
| 1392 |
+
)
|
| 1393 |
+
|
| 1394 |
+
|
| 1395 |
+
# =============================================================================
|
| 1396 |
+
# Public Registry Endpoints (Week 10 Day 5 - Public Certification Registry)
|
| 1397 |
+
# =============================================================================
|
| 1398 |
+
|
| 1399 |
+
class CertificateResponse(BaseModel):
|
| 1400 |
+
"""Response model for certificate."""
|
| 1401 |
+
certificate_id: str
|
| 1402 |
+
model_name: str
|
| 1403 |
+
model_version: str
|
| 1404 |
+
organization: str
|
| 1405 |
+
certification_tier: str
|
| 1406 |
+
risk_tier: str
|
| 1407 |
+
gss_score: float
|
| 1408 |
+
RSI: float
|
| 1409 |
+
RiskIndex: float
|
| 1410 |
+
evaluation_date: str
|
| 1411 |
+
valid_until: str
|
| 1412 |
+
status: str
|
| 1413 |
+
sector: Optional[str] = None
|
| 1414 |
+
model_size: Optional[str] = None
|
| 1415 |
+
|
| 1416 |
+
|
| 1417 |
+
class VerificationResponse(BaseModel):
|
| 1418 |
+
"""Response model for certificate verification."""
|
| 1419 |
+
certificate_id: str
|
| 1420 |
+
is_valid: bool
|
| 1421 |
+
message: str
|
| 1422 |
+
model_name: Optional[str] = None
|
| 1423 |
+
model_version: Optional[str] = None
|
| 1424 |
+
certification_tier: Optional[str] = None
|
| 1425 |
+
gss_score: Optional[float] = None
|
| 1426 |
+
valid_until: Optional[str] = None
|
| 1427 |
+
|
| 1428 |
+
|
| 1429 |
+
class LeaderboardResponse(BaseModel):
|
| 1430 |
+
"""Response model for leaderboard."""
|
| 1431 |
+
category: str
|
| 1432 |
+
title: str
|
| 1433 |
+
description: str
|
| 1434 |
+
entries: List[Dict[str, Any]]
|
| 1435 |
+
total_entries: int
|
| 1436 |
+
generated_at: str
|
| 1437 |
+
|
| 1438 |
+
|
| 1439 |
+
class TransparencyReportResponse(BaseModel):
|
| 1440 |
+
"""Response model for transparency report."""
|
| 1441 |
+
report_id: str
|
| 1442 |
+
quarter: str
|
| 1443 |
+
year: int
|
| 1444 |
+
period_start: str
|
| 1445 |
+
period_end: str
|
| 1446 |
+
generated_at: str
|
| 1447 |
+
metrics: Dict[str, Any]
|
| 1448 |
+
changes: Dict[str, Any]
|
| 1449 |
+
drift_events: List[Dict[str, Any]]
|
| 1450 |
+
incidents: List[Dict[str, Any]]
|
| 1451 |
+
|
| 1452 |
+
|
| 1453 |
+
@router.get("/registry/certificate/{certificate_id}", response_model=CertificateResponse)
|
| 1454 |
+
async def get_certificate(
|
| 1455 |
+
certificate_id: str,
|
| 1456 |
+
db: AsyncSession = Depends(get_db),
|
| 1457 |
+
):
|
| 1458 |
+
"""
|
| 1459 |
+
Get certificate by ID (Public Endpoint).
|
| 1460 |
+
|
| 1461 |
+
Returns the certificate with the given ID. This endpoint is public
|
| 1462 |
+
and does not require authentication.
|
| 1463 |
+
"""
|
| 1464 |
+
from sqlalchemy import select
|
| 1465 |
+
|
| 1466 |
+
result = await db.execute(
|
| 1467 |
+
select(Certificate).where(Certificate.certificate_id == certificate_id)
|
| 1468 |
+
)
|
| 1469 |
+
certificate = result.scalar_one_or_none()
|
| 1470 |
+
|
| 1471 |
+
if certificate is None:
|
| 1472 |
+
raise HTTPException(
|
| 1473 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1474 |
+
detail=f"Certificate {certificate_id} not found"
|
| 1475 |
+
)
|
| 1476 |
+
|
| 1477 |
+
return CertificateResponse(
|
| 1478 |
+
certificate_id=certificate.certificate_id,
|
| 1479 |
+
model_name=certificate.model_name,
|
| 1480 |
+
model_version=certificate.model_version,
|
| 1481 |
+
organization=certificate.organization,
|
| 1482 |
+
certification_tier=certificate.certification_tier,
|
| 1483 |
+
risk_tier=certificate.risk_tier,
|
| 1484 |
+
gss_score=certificate.gss_score,
|
| 1485 |
+
RSI=certificate.RSI,
|
| 1486 |
+
RiskIndex=certificate.RiskIndex,
|
| 1487 |
+
evaluation_date=certificate.evaluation_date.isoformat() + "Z",
|
| 1488 |
+
valid_until=certificate.valid_until.isoformat() + "Z",
|
| 1489 |
+
status=certificate.status,
|
| 1490 |
+
sector=certificate.sector,
|
| 1491 |
+
model_size=certificate.model_size,
|
| 1492 |
+
)
|
| 1493 |
+
|
| 1494 |
+
|
| 1495 |
+
@router.get("/registry/verify/{certificate_id}", response_model=VerificationResponse)
|
| 1496 |
+
async def verify_certificate(
|
| 1497 |
+
certificate_id: str,
|
| 1498 |
+
db: AsyncSession = Depends(get_db),
|
| 1499 |
+
):
|
| 1500 |
+
"""
|
| 1501 |
+
Verify certificate authenticity (Public Endpoint).
|
| 1502 |
+
|
| 1503 |
+
Verifies the digital signature and validity of a certificate.
|
| 1504 |
+
This endpoint is public and does not require authentication.
|
| 1505 |
+
"""
|
| 1506 |
+
from sqlalchemy import select
|
| 1507 |
+
from public_registry.verification_engine import CertificateVerifier, VerificationResult
|
| 1508 |
+
|
| 1509 |
+
# Get certificate from database
|
| 1510 |
+
result = await db.execute(
|
| 1511 |
+
select(Certificate).where(Certificate.certificate_id == certificate_id)
|
| 1512 |
+
)
|
| 1513 |
+
certificate = result.scalar_one_or_none()
|
| 1514 |
+
|
| 1515 |
+
if certificate is None:
|
| 1516 |
+
return VerificationResponse(
|
| 1517 |
+
certificate_id=certificate_id,
|
| 1518 |
+
is_valid=False,
|
| 1519 |
+
message="Certificate not found"
|
| 1520 |
+
)
|
| 1521 |
+
|
| 1522 |
+
# Create verifier with public key (would be loaded from config in production)
|
| 1523 |
+
verifier = CertificateVerifier("aegislm-public-key-v1")
|
| 1524 |
+
|
| 1525 |
+
# Create certificate data for verification
|
| 1526 |
+
cert_data = {
|
| 1527 |
+
"certificate_id": certificate.certificate_id,
|
| 1528 |
+
"model_name": certificate.model_name,
|
| 1529 |
+
"model_version": certificate.model_version,
|
| 1530 |
+
"organization": certificate.organization,
|
| 1531 |
+
"risk_tier": certificate.risk_tier,
|
| 1532 |
+
"gss_score": certificate.gss_score,
|
| 1533 |
+
"certification_tier": certificate.certification_tier,
|
| 1534 |
+
"RSI": certificate.RSI,
|
| 1535 |
+
"RiskIndex": certificate.RiskIndex,
|
| 1536 |
+
"evaluation_date": certificate.evaluation_date.isoformat() + "Z",
|
| 1537 |
+
"valid_until": certificate.valid_until.isoformat() + "Z",
|
| 1538 |
+
"audit_hash": certificate.audit_hash,
|
| 1539 |
+
}
|
| 1540 |
+
|
| 1541 |
+
# Verify certificate
|
| 1542 |
+
verification_result = verifier.verify_certificate_dict({
|
| 1543 |
+
"certificate": cert_data,
|
| 1544 |
+
"verification_signature": certificate.verification_signature,
|
| 1545 |
+
"signature_algorithm": "Ed25519"
|
| 1546 |
+
})
|
| 1547 |
+
|
| 1548 |
+
return VerificationResponse(
|
| 1549 |
+
certificate_id=certificate_id,
|
| 1550 |
+
is_valid=verification_result.is_valid,
|
| 1551 |
+
message=verification_result.message,
|
| 1552 |
+
model_name=certificate.model_name if verification_result.is_valid else None,
|
| 1553 |
+
model_version=certificate.model_version if verification_result.is_valid else None,
|
| 1554 |
+
certification_tier=certificate.certification_tier if verification_result.is_valid else None,
|
| 1555 |
+
gss_score=certificate.gss_score if verification_result.is_valid else None,
|
| 1556 |
+
valid_until=certificate.valid_until.isoformat() + "Z" if verification_result.is_valid else None,
|
| 1557 |
+
)
|
| 1558 |
+
|
| 1559 |
+
|
| 1560 |
+
@router.get("/leaderboard", response_model=LeaderboardResponse)
|
| 1561 |
+
async def get_leaderboard(
|
| 1562 |
+
category: str = Query("overall", description="Leaderboard category"),
|
| 1563 |
+
limit: int = Query(100, ge=1, le=500, description="Maximum entries to return"),
|
| 1564 |
+
db: AsyncSession = Depends(get_db),
|
| 1565 |
+
):
|
| 1566 |
+
"""
|
| 1567 |
+
Get model robustness leaderboard (Public Endpoint).
|
| 1568 |
+
|
| 1569 |
+
Returns the current leaderboard rankings. This endpoint is public
|
| 1570 |
+
and does not require authentication.
|
| 1571 |
+
"""
|
| 1572 |
+
from sqlalchemy import select, desc
|
| 1573 |
+
from leaderboard.leaderboard_engine import LeaderboardEngine
|
| 1574 |
+
|
| 1575 |
+
# For now, we'll implement a simple database query
|
| 1576 |
+
# In production, this would use the LeaderboardEngine with caching
|
| 1577 |
+
|
| 1578 |
+
query = (
|
| 1579 |
+
select(Certificate)
|
| 1580 |
+
.where(Certificate.status == "active")
|
| 1581 |
+
.order_by(desc(Certificate.gss_score))
|
| 1582 |
+
.limit(limit)
|
| 1583 |
+
)
|
| 1584 |
+
|
| 1585 |
+
result = await db.execute(query)
|
| 1586 |
+
certificates = result.scalars().all()
|
| 1587 |
+
|
| 1588 |
+
entries = []
|
| 1589 |
+
for i, cert in enumerate(certificates):
|
| 1590 |
+
# Calculate leaderboard score (simplified)
|
| 1591 |
+
leaderboard_score = cert.gss_score * cert.RSI * (1 - cert.RiskIndex)
|
| 1592 |
+
|
| 1593 |
+
entries.append({
|
| 1594 |
+
"rank": i + 1,
|
| 1595 |
+
"model_name": cert.model_name,
|
| 1596 |
+
"model_version": cert.model_version,
|
| 1597 |
+
"organization": cert.organization,
|
| 1598 |
+
"certification_tier": cert.certification_tier,
|
| 1599 |
+
"risk_tier": cert.risk_tier,
|
| 1600 |
+
"gss_score": cert.gss_score,
|
| 1601 |
+
"rsi": cert.RSI,
|
| 1602 |
+
"risk_index": cert.RiskIndex,
|
| 1603 |
+
"leaderboard_score": round(leaderboard_score, 6),
|
| 1604 |
+
"evaluation_date": cert.evaluation_date.isoformat() + "Z",
|
| 1605 |
+
"certificate_id": cert.certificate_id,
|
| 1606 |
+
})
|
| 1607 |
+
|
| 1608 |
+
return LeaderboardResponse(
|
| 1609 |
+
category=category,
|
| 1610 |
+
title="Overall Model Robustness Leaderboard",
|
| 1611 |
+
description="Rankings based on overall robustness, stability, and risk profile",
|
| 1612 |
+
entries=entries,
|
| 1613 |
+
total_entries=len(entries),
|
| 1614 |
+
generated_at=datetime.utcnow().isoformat() + "Z",
|
| 1615 |
+
)
|
| 1616 |
+
|
| 1617 |
+
|
| 1618 |
+
@router.get("/transparency/report", response_model=TransparencyReportResponse)
|
| 1619 |
+
async def get_transparency_report(
|
| 1620 |
+
quarter: str = Query(None, description="Quarter (e.g., Q1)"),
|
| 1621 |
+
year: int = Query(None, description="Year"),
|
| 1622 |
+
db: AsyncSession = Depends(get_db),
|
| 1623 |
+
):
|
| 1624 |
+
"""
|
| 1625 |
+
Get transparency report (Public Endpoint).
|
| 1626 |
+
|
| 1627 |
+
Returns the latest transparency report or a specific quarter/year.
|
| 1628 |
+
This endpoint is public and does not require authentication.
|
| 1629 |
+
"""
|
| 1630 |
+
from transparency_portal.transparency_report import TransparencyReportGenerator
|
| 1631 |
+
|
| 1632 |
+
# Generate sample report (in production, this would load from database/cache)
|
| 1633 |
+
generator = TransparencyReportGenerator()
|
| 1634 |
+
|
| 1635 |
+
if not quarter or not year:
|
| 1636 |
+
# Get current quarter
|
| 1637 |
+
now = datetime.utcnow()
|
| 1638 |
+
quarter_num = (now.month - 1) // 3 + 1
|
| 1639 |
+
quarter = f"Q{quarter_num}"
|
| 1640 |
+
year = now.year
|
| 1641 |
+
|
| 1642 |
+
report = generator.generate_sample_report(quarter, year)
|
| 1643 |
+
|
| 1644 |
+
return TransparencyReportResponse(
|
| 1645 |
+
report_id=report.report_id,
|
| 1646 |
+
quarter=report.quarter,
|
| 1647 |
+
year=report.year,
|
| 1648 |
+
period_start=report.period_start,
|
| 1649 |
+
period_end=report.period_end,
|
| 1650 |
+
generated_at=report.generated_at,
|
| 1651 |
+
metrics=generator.to_dict(report)["metrics"],
|
| 1652 |
+
changes=generator.to_dict(report)["changes"],
|
| 1653 |
+
drift_events=generator.to_dict(report)["drift_events"],
|
| 1654 |
+
incidents=generator.to_dict(report)["incidents"],
|
| 1655 |
+
)
|
| 1656 |
+
|
| 1657 |
+
|
| 1658 |
+
# =============================================================================
|
| 1659 |
+
# Ecosystem Analytics Endpoints (Week 11 Day 2 - AI Governance Ecosystem)
|
| 1660 |
+
# =============================================================================
|
| 1661 |
+
|
| 1662 |
+
class EcosystemDashboardResponse(BaseModel):
|
| 1663 |
+
"""Response model for ecosystem dashboard."""
|
| 1664 |
+
total_active_models: int
|
| 1665 |
+
average_ecosystem_robustness: float
|
| 1666 |
+
certification_distribution: Dict[str, int]
|
| 1667 |
+
sector_metrics: List[Dict[str, Any]]
|
| 1668 |
+
attack_trends: List[Dict[str, Any]]
|
| 1669 |
+
vulnerability_patterns: List[Dict[str, Any]]
|
| 1670 |
+
last_updated: str
|
| 1671 |
+
participating_organizations: int
|
| 1672 |
+
total_evaluations: int
|
| 1673 |
+
|
| 1674 |
+
|
| 1675 |
+
class SectorComparisonResponse(BaseModel):
|
| 1676 |
+
"""Response model for sector comparison."""
|
| 1677 |
+
sector_average: float
|
| 1678 |
+
your_score: float
|
| 1679 |
+
delta: float
|
| 1680 |
+
percentile: int
|
| 1681 |
+
sample_count: int
|
| 1682 |
+
risk_assessment: str
|
| 1683 |
+
|
| 1684 |
+
|
| 1685 |
+
@router.get("/ecosystem/dashboard", response_model=EcosystemDashboardResponse)
|
| 1686 |
+
async def get_ecosystem_dashboard():
|
| 1687 |
+
"""
|
| 1688 |
+
Get ecosystem dashboard data (Public Endpoint).
|
| 1689 |
+
|
| 1690 |
+
Returns aggregated, anonymized ecosystem metrics including:
|
| 1691 |
+
- Average robustness by industry
|
| 1692 |
+
- Certification distribution
|
| 1693 |
+
- Attack vulnerability trends
|
| 1694 |
+
- Vulnerability patterns
|
| 1695 |
+
|
| 1696 |
+
All data is aggregated with no tenant identifiers exposed.
|
| 1697 |
+
"""
|
| 1698 |
+
from ecosystem.analytics_engine import create_analytics_engine
|
| 1699 |
+
|
| 1700 |
+
engine = create_analytics_engine()
|
| 1701 |
+
dashboard_data = engine.get_dashboard_data()
|
| 1702 |
+
|
| 1703 |
+
return EcosystemDashboardResponse(
|
| 1704 |
+
total_active_models=dashboard_data.total_active_models,
|
| 1705 |
+
average_ecosystem_robustness=dashboard_data.average_ecosystem_robustness,
|
| 1706 |
+
certification_distribution={
|
| 1707 |
+
"Tier A": dashboard_data.certification_distribution.tier_a_count,
|
| 1708 |
+
"Tier B": dashboard_data.certification_distribution.tier_b_count,
|
| 1709 |
+
"Tier C": dashboard_data.certification_distribution.tier_c_count,
|
| 1710 |
+
"Tier D": dashboard_data.certification_distribution.tier_d_count,
|
| 1711 |
+
},
|
| 1712 |
+
sector_metrics=[
|
| 1713 |
+
{
|
| 1714 |
+
"sector": s.sector,
|
| 1715 |
+
"avg_robustness": s.avg_robustness,
|
| 1716 |
+
"median_robustness": s.median_robustness,
|
| 1717 |
+
"std_deviation": s.std_deviation,
|
| 1718 |
+
"sample_count": s.sample_count,
|
| 1719 |
+
"tier_distribution": s.tier_distribution,
|
| 1720 |
+
}
|
| 1721 |
+
for s in dashboard_data.sector_metrics
|
| 1722 |
+
],
|
| 1723 |
+
attack_trends=[
|
| 1724 |
+
{
|
| 1725 |
+
"attack_type": a.attack_type,
|
| 1726 |
+
"success_rate": a.success_rate,
|
| 1727 |
+
"trend_percent": a.trend_percent,
|
| 1728 |
+
"sample_count": a.sample_count,
|
| 1729 |
+
}
|
| 1730 |
+
for a in dashboard_data.attack_trends
|
| 1731 |
+
],
|
| 1732 |
+
vulnerability_patterns=[
|
| 1733 |
+
{
|
| 1734 |
+
"pattern_id": v.pattern_id,
|
| 1735 |
+
"pattern_type": v.pattern_type,
|
| 1736 |
+
"description": v.description,
|
| 1737 |
+
"severity": v.severity,
|
| 1738 |
+
"affected_sectors": v.affected_sectors,
|
| 1739 |
+
"occurrence_count": v.occurrence_count,
|
| 1740 |
+
}
|
| 1741 |
+
for v in dashboard_data.vulnerability_patterns
|
| 1742 |
+
],
|
| 1743 |
+
last_updated=dashboard_data.last_updated.isoformat() + "Z",
|
| 1744 |
+
participating_organizations=dashboard_data.participating_organizations,
|
| 1745 |
+
total_evaluations=dashboard_data.total_evaluations,
|
| 1746 |
+
)
|
| 1747 |
+
|
| 1748 |
+
|
| 1749 |
+
@router.get("/ecosystem/sectors/{sector}/compare", response_model=SectorComparisonResponse)
|
| 1750 |
+
async def compare_against_sector(
|
| 1751 |
+
sector: str,
|
| 1752 |
+
robustness_score: float = Query(..., ge=0.0, le=1.0, description="Robustness score to compare"),
|
| 1753 |
+
):
|
| 1754 |
+
"""
|
| 1755 |
+
Compare model robustness against sector average (Enterprise Endpoint).
|
| 1756 |
+
|
| 1757 |
+
Returns comparison metrics including:
|
| 1758 |
+
- Sector average robustness
|
| 1759 |
+
- Delta from sector average
|
| 1760 |
+
- Percentile ranking
|
| 1761 |
+
- Risk assessment
|
| 1762 |
+
"""
|
| 1763 |
+
from ecosystem.analytics_engine import create_analytics_engine
|
| 1764 |
+
|
| 1765 |
+
engine = create_analytics_engine()
|
| 1766 |
+
result = engine.compare_against_sector(robustness_score, sector)
|
| 1767 |
+
|
| 1768 |
+
return SectorComparisonResponse(**result)
|
| 1769 |
+
|
| 1770 |
+
|
| 1771 |
+
@router.get("/ecosystem/privacy-compliance")
|
| 1772 |
+
async def verify_ecosystem_privacy():
|
| 1773 |
+
"""
|
| 1774 |
+
Verify ecosystem data privacy compliance (Public Endpoint).
|
| 1775 |
+
|
| 1776 |
+
Returns privacy compliance status and verification details.
|
| 1777 |
+
"""
|
| 1778 |
+
from ecosystem.analytics_engine import create_analytics_engine
|
| 1779 |
+
|
| 1780 |
+
engine = create_analytics_engine()
|
| 1781 |
+
compliance = engine.verify_privacy_compliance()
|
| 1782 |
+
|
| 1783 |
+
return compliance
|
| 1784 |
+
|
| 1785 |
+
|
| 1786 |
+
# =============================================================================
|
| 1787 |
+
# Plugin Marketplace Endpoints (Week 11 Day 2 - Plugin Marketplace)
|
| 1788 |
+
# =============================================================================
|
| 1789 |
+
|
| 1790 |
+
class PluginSubmissionResponse(BaseModel):
|
| 1791 |
+
"""Response model for plugin submission."""
|
| 1792 |
+
plugin_id: str
|
| 1793 |
+
name: str
|
| 1794 |
+
version: str
|
| 1795 |
+
status: str
|
| 1796 |
+
submitted_at: str
|
| 1797 |
+
|
| 1798 |
+
|
| 1799 |
+
class PluginListResponse(BaseModel):
|
| 1800 |
+
"""Response model for plugin list."""
|
| 1801 |
+
plugins: List[Dict[str, Any]]
|
| 1802 |
+
total: int
|
| 1803 |
+
|
| 1804 |
+
|
| 1805 |
+
class PluginRatingResponse(BaseModel):
|
| 1806 |
+
"""Response model for plugin rating."""
|
| 1807 |
+
plugin_id: str
|
| 1808 |
+
average_rating: float
|
| 1809 |
+
rating_count: int
|
| 1810 |
+
plugin_score: float
|
| 1811 |
+
|
| 1812 |
+
|
| 1813 |
+
@router.post("/marketplace/plugins", response_model=PluginSubmissionResponse)
|
| 1814 |
+
async def submit_plugin(
|
| 1815 |
+
request: PluginSubmissionRequest,
|
| 1816 |
+
):
|
| 1817 |
+
"""
|
| 1818 |
+
Submit a new plugin for review.
|
| 1819 |
+
|
| 1820 |
+
Creates a new plugin submission with security compliance checks.
|
| 1821 |
+
"""
|
| 1822 |
+
from marketplace.plugin_submission_api import create_submission_api
|
| 1823 |
+
|
| 1824 |
+
api = create_submission_api()
|
| 1825 |
+
submission = api.submit_plugin(request)
|
| 1826 |
+
|
| 1827 |
+
return PluginSubmissionResponse(
|
| 1828 |
+
plugin_id=submission.plugin_id,
|
| 1829 |
+
name=submission.name,
|
| 1830 |
+
version=submission.version,
|
| 1831 |
+
status=submission.status.value,
|
| 1832 |
+
submitted_at=submission.submitted_at.isoformat() + "Z",
|
| 1833 |
+
)
|
| 1834 |
+
|
| 1835 |
+
|
| 1836 |
+
@router.get("/marketplace/plugins", response_model=PluginListResponse)
|
| 1837 |
+
async def list_plugins(
|
| 1838 |
+
status: Optional[str] = Query(None, description="Filter by review status"),
|
| 1839 |
+
sector: Optional[str] = Query(None, description="Filter by sector"),
|
| 1840 |
+
plugin_type: Optional[str] = Query(None, description="Filter by plugin type"),
|
| 1841 |
+
):
|
| 1842 |
+
"""
|
| 1843 |
+
List marketplace plugins (Public Endpoint).
|
| 1844 |
+
|
| 1845 |
+
Returns a list of approved plugins with optional filtering.
|
| 1846 |
+
"""
|
| 1847 |
+
from marketplace.plugin_submission_api import create_submission_api, PluginReviewStatus
|
| 1848 |
+
|
| 1849 |
+
api = create_submission_api()
|
| 1850 |
+
|
| 1851 |
+
# Convert string to enum if provided
|
| 1852 |
+
status_enum = PluginReviewStatus(status) if status else None
|
| 1853 |
+
|
| 1854 |
+
plugins = api.list_plugins(status=status_enum, sector=sector, plugin_type=plugin_type)
|
| 1855 |
+
|
| 1856 |
+
return PluginListResponse(
|
| 1857 |
+
plugins=[
|
| 1858 |
+
{
|
| 1859 |
+
"plugin_id": p.plugin_id,
|
| 1860 |
+
"name": p.name,
|
| 1861 |
+
"version": p.version,
|
| 1862 |
+
"author": p.author,
|
| 1863 |
+
"sector": p.sector,
|
| 1864 |
+
"plugin_type": p.plugin_type,
|
| 1865 |
+
"status": p.status.value,
|
| 1866 |
+
"average_rating": p.average_rating,
|
| 1867 |
+
"usage_count": p.usage_count,
|
| 1868 |
+
}
|
| 1869 |
+
for p in plugins
|
| 1870 |
+
],
|
| 1871 |
+
total=len(plugins),
|
| 1872 |
+
)
|
| 1873 |
+
|
| 1874 |
+
|
| 1875 |
+
@router.get("/marketplace/plugins/{plugin_id}", response_model=Dict[str, Any])
|
| 1876 |
+
async def get_plugin(
|
| 1877 |
+
plugin_id: str,
|
| 1878 |
+
):
|
| 1879 |
+
"""
|
| 1880 |
+
Get plugin details by ID (Public Endpoint).
|
| 1881 |
+
|
| 1882 |
+
Returns detailed plugin information including ratings and usage.
|
| 1883 |
+
"""
|
| 1884 |
+
from marketplace.plugin_submission_api import create_submission_api
|
| 1885 |
+
|
| 1886 |
+
api = create_submission_api()
|
| 1887 |
+
plugin = api.get_plugin(plugin_id)
|
| 1888 |
+
|
| 1889 |
+
if not plugin:
|
| 1890 |
+
raise HTTPException(
|
| 1891 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1892 |
+
detail=f"Plugin {plugin_id} not found"
|
| 1893 |
+
)
|
| 1894 |
+
|
| 1895 |
+
return {
|
| 1896 |
+
"plugin_id": plugin.plugin_id,
|
| 1897 |
+
"name": plugin.name,
|
| 1898 |
+
"version": plugin.version,
|
| 1899 |
+
"author": plugin.author,
|
| 1900 |
+
"description": plugin.description,
|
| 1901 |
+
"sector": plugin.sector,
|
| 1902 |
+
"plugin_type": plugin.plugin_type,
|
| 1903 |
+
"status": plugin.status.value,
|
| 1904 |
+
"security_status": plugin.security_status.value,
|
| 1905 |
+
"average_rating": plugin.average_rating,
|
| 1906 |
+
"rating_count": plugin.rating_count,
|
| 1907 |
+
"usage_count": plugin.usage_count,
|
| 1908 |
+
"submitted_at": plugin.submitted_at.isoformat() + "Z",
|
| 1909 |
+
}
|
| 1910 |
+
|
| 1911 |
+
|
| 1912 |
+
@router.get("/marketplace/plugins/{plugin_id}/score", response_model=PluginRatingResponse)
|
| 1913 |
+
async def get_plugin_score(
|
| 1914 |
+
plugin_id: str,
|
| 1915 |
+
):
|
| 1916 |
+
"""
|
| 1917 |
+
Get plugin score (Public Endpoint).
|
| 1918 |
+
|
| 1919 |
+
Returns calculated plugin score using the ranking formula:
|
| 1920 |
+
PluginScore = 0.5 * R_rating + 0.5 * UsageWeight
|
| 1921 |
+
"""
|
| 1922 |
+
from marketplace.plugin_submission_api import create_submission_api
|
| 1923 |
+
|
| 1924 |
+
api = create_submission_api()
|
| 1925 |
+
plugin = api.get_plugin(plugin_id)
|
| 1926 |
+
|
| 1927 |
+
if not plugin:
|
| 1928 |
+
raise HTTPException(
|
| 1929 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1930 |
+
detail=f"Plugin {plugin_id} not found"
|
| 1931 |
+
)
|
| 1932 |
+
|
| 1933 |
+
score = api.calculate_plugin_score(plugin_id)
|
| 1934 |
+
|
| 1935 |
+
return PluginRatingResponse(
|
| 1936 |
+
plugin_id=plugin_id,
|
| 1937 |
+
average_rating=plugin.average_rating,
|
| 1938 |
+
rating_count=plugin.rating_count,
|
| 1939 |
+
plugin_score=score,
|
| 1940 |
+
)
|
| 1941 |
+
|
| 1942 |
+
|
| 1943 |
+
# =============================================================================
|
| 1944 |
+
# Federation & External Submission Endpoints (Week 11 Day 2 - Federation)
|
| 1945 |
+
# =============================================================================
|
| 1946 |
+
|
| 1947 |
+
class FederatedSubmissionResponse(BaseModel):
|
| 1948 |
+
"""Response for federated score submission."""
|
| 1949 |
+
submission_id: str
|
| 1950 |
+
status: str
|
| 1951 |
+
validation_timestamp: str
|
| 1952 |
+
checks_passed: List[str]
|
| 1953 |
+
failures: List[Dict[str, Any]]
|
| 1954 |
+
is_valid: bool
|
| 1955 |
+
|
| 1956 |
+
|
| 1957 |
+
class TrustScoreResponse(BaseModel):
|
| 1958 |
+
"""Response for partner trust score."""
|
| 1959 |
+
partner_id: str
|
| 1960 |
+
trust_score: float
|
| 1961 |
+
total_certificates: int
|
| 1962 |
+
revoked_certificates: int
|
| 1963 |
+
accuracy_rate: float
|
| 1964 |
+
|
| 1965 |
+
|
| 1966 |
+
@router.post("/federation/submit", response_model=FederatedSubmissionResponse)
|
| 1967 |
+
async def submit_federated_score(
|
| 1968 |
+
submission: FederatedScoreSubmission,
|
| 1969 |
+
):
|
| 1970 |
+
"""
|
| 1971 |
+
Submit GSS-compatible scores from external evaluation system.
|
| 1972 |
+
|
| 1973 |
+
Validates the submission including:
|
| 1974 |
+
- Digital signature verification
|
| 1975 |
+
- Metric bounds validation
|
| 1976 |
+
- GSS version compatibility
|
| 1977 |
+
- Reproducibility metadata validation
|
| 1978 |
+
|
| 1979 |
+
Returns validation result with detailed status.
|
| 1980 |
+
"""
|
| 1981 |
+
from federation.federated_verification_engine import create_verification_engine
|
| 1982 |
+
|
| 1983 |
+
engine = create_verification_engine()
|
| 1984 |
+
result = engine.validate_submission(submission)
|
| 1985 |
+
|
| 1986 |
+
return FederatedSubmissionResponse(
|
| 1987 |
+
submission_id=result.submission_id,
|
| 1988 |
+
status=result.status.value,
|
| 1989 |
+
validation_timestamp=result.validation_timestamp.isoformat() + "Z",
|
| 1990 |
+
checks_passed=result.checks_passed,
|
| 1991 |
+
failures=result.failures,
|
| 1992 |
+
is_valid=result.is_valid,
|
| 1993 |
+
)
|
| 1994 |
+
|
| 1995 |
+
|
| 1996 |
+
@router.get("/federation/partners/{partner_id}/trust", response_model=TrustScoreResponse)
|
| 1997 |
+
async def get_partner_trust(
|
| 1998 |
+
partner_id: str,
|
| 1999 |
+
):
|
| 2000 |
+
"""
|
| 2001 |
+
Get partner trust score (Public Endpoint).
|
| 2002 |
+
|
| 2003 |
+
Returns trust score calculated using:
|
| 2004 |
+
TrustScore = 1 - (RevokedCertificates / TotalCertificates)
|
| 2005 |
+
"""
|
| 2006 |
+
from federation.federated_verification_engine import create_verification_engine
|
| 2007 |
+
|
| 2008 |
+
engine = create_verification_engine()
|
| 2009 |
+
trust = engine.compute_trust_score(partner_id)
|
| 2010 |
+
|
| 2011 |
+
if not trust:
|
| 2012 |
+
raise HTTPException(
|
| 2013 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 2014 |
+
detail=f"Partner {partner_id} not found"
|
| 2015 |
+
)
|
| 2016 |
+
|
| 2017 |
+
return TrustScoreResponse(
|
| 2018 |
+
partner_id=trust.partner_id,
|
| 2019 |
+
trust_score=trust.trust_score,
|
| 2020 |
+
total_certificates=trust.total_certificates,
|
| 2021 |
+
revoked_certificates=trust.revoked_certificates,
|
| 2022 |
+
accuracy_rate=trust.accuracy_rate,
|
| 2023 |
+
)
|
| 2024 |
+
|
| 2025 |
+
|
| 2026 |
+
@router.get("/federation/partners", response_model=List[TrustScoreResponse])
|
| 2027 |
+
async def list_partner_trust_scores():
|
| 2028 |
+
"""
|
| 2029 |
+
List all partner trust scores (Public Endpoint).
|
| 2030 |
+
|
| 2031 |
+
Returns trust scores for all accredited partners.
|
| 2032 |
+
"""
|
| 2033 |
+
from federation.federated_verification_engine import create_verification_engine
|
| 2034 |
+
|
| 2035 |
+
engine = create_verification_engine()
|
| 2036 |
+
trust_scores = engine.get_all_trust_scores()
|
| 2037 |
+
|
| 2038 |
+
return [
|
| 2039 |
+
TrustScoreResponse(
|
| 2040 |
+
partner_id=t.partner_id,
|
| 2041 |
+
trust_score=t.trust_score,
|
| 2042 |
+
total_certificates=t.total_certificates,
|
| 2043 |
+
revoked_certificates=t.revoked_certificates,
|
| 2044 |
+
accuracy_rate=t.accuracy_rate,
|
| 2045 |
+
)
|
| 2046 |
+
for t in trust_scores
|
| 2047 |
+
]
|
| 2048 |
+
|
| 2049 |
+
|
| 2050 |
+
@router.get("/federation/spec")
|
| 2051 |
+
async def get_interoperability_spec():
|
| 2052 |
+
"""
|
| 2053 |
+
Get federation interoperability specification (Public Endpoint).
|
| 2054 |
+
|
| 2055 |
+
Returns the standard for external GSS-compatible submissions including:
|
| 2056 |
+
- Required metric fields
|
| 2057 |
+
- JSON schema
|
| 2058 |
+
- Signature verification requirements
|
| 2059 |
+
- GSS version compatibility
|
| 2060 |
+
- Reproducibility metadata requirements
|
| 2061 |
+
"""
|
| 2062 |
+
from federation.federated_verification_engine import create_verification_engine
|
| 2063 |
+
|
| 2064 |
+
engine = create_verification_engine()
|
| 2065 |
+
return engine.get_interoperability_spec()
|
| 2066 |
+
|
| 2067 |
+
|
| 2068 |
+
# =============================================================================
|
| 2069 |
+
# Certification Partners Endpoints (Week 11 Day 2 - Delegated Certification)
|
| 2070 |
+
# =============================================================================
|
| 2071 |
+
|
| 2072 |
+
class PartnerAccreditationResponse(BaseModel):
|
| 2073 |
+
"""Response for partner accreditation."""
|
| 2074 |
+
partner_id: str
|
| 2075 |
+
partner_name: str
|
| 2076 |
+
organization: str
|
| 2077 |
+
status: str
|
| 2078 |
+
trust_score: float
|
| 2079 |
+
allowed_sectors: List[str]
|
| 2080 |
+
allowed_tiers: List[str]
|
| 2081 |
+
|
| 2082 |
+
|
| 2083 |
+
class CertificateIssueResponse(BaseModel):
|
| 2084 |
+
"""Response for issued certificate."""
|
| 2085 |
+
certificate_id: str
|
| 2086 |
+
model_name: str
|
| 2087 |
+
certification_tier: str
|
| 2088 |
+
certified_by: str
|
| 2089 |
+
co_signed_by: str
|
| 2090 |
+
valid_until: str
|
| 2091 |
+
|
| 2092 |
+
|
| 2093 |
+
@router.get("/partners/accredited", response_model=List[PartnerAccreditationResponse])
|
| 2094 |
+
async def list_accredited_partners(
|
| 2095 |
+
status: Optional[str] = Query(None, description="Filter by status"),
|
| 2096 |
+
):
|
| 2097 |
+
"""
|
| 2098 |
+
List accredited certification partners (Public Endpoint).
|
| 2099 |
+
|
| 2100 |
+
Returns list of partners authorized to issue certificates.
|
| 2101 |
+
"""
|
| 2102 |
+
from certification_partners.delegated_certification import (
|
| 2103 |
+
create_certification_engine,
|
| 2104 |
+
PartnerStatus,
|
| 2105 |
+
)
|
| 2106 |
+
|
| 2107 |
+
engine = create_certification_engine()
|
| 2108 |
+
|
| 2109 |
+
# Convert string to enum if provided
|
| 2110 |
+
status_enum = PartnerStatus(status) if status else None
|
| 2111 |
+
|
| 2112 |
+
partners = engine.list_partners(status=status_enum)
|
| 2113 |
+
|
| 2114 |
+
return [
|
| 2115 |
+
PartnerAccreditationResponse(
|
| 2116 |
+
partner_id=p.partner_id,
|
| 2117 |
+
partner_name=p.partner_name,
|
| 2118 |
+
organization=p.organization,
|
| 2119 |
+
status=p.status.value,
|
| 2120 |
+
trust_score=p.trust_score,
|
| 2121 |
+
allowed_sectors=p.allowed_sectors,
|
| 2122 |
+
allowed_tiers=p.allowed_tiers,
|
| 2123 |
+
)
|
| 2124 |
+
for p in partners
|
| 2125 |
+
]
|
| 2126 |
+
|
| 2127 |
+
|
| 2128 |
+
@router.get("/partners/{partner_id}", response_model=PartnerAccreditationResponse)
|
| 2129 |
+
async def get_partner_accreditation(
|
| 2130 |
+
partner_id: str,
|
| 2131 |
+
):
|
| 2132 |
+
"""
|
| 2133 |
+
Get partner accreditation details (Public Endpoint).
|
| 2134 |
+
|
| 2135 |
+
Returns detailed accreditation information for a partner.
|
| 2136 |
+
"""
|
| 2137 |
+
from certification_partners.delegated_certification import create_certification_engine
|
| 2138 |
+
|
| 2139 |
+
engine = create_certification_engine()
|
| 2140 |
+
partner = engine.get_partner(partner_id)
|
| 2141 |
+
|
| 2142 |
+
if not partner:
|
| 2143 |
+
raise HTTPException(
|
| 2144 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 2145 |
+
detail=f"Partner {partner_id} not found"
|
| 2146 |
+
)
|
| 2147 |
+
|
| 2148 |
+
return PartnerAccreditationResponse(
|
| 2149 |
+
partner_id=partner.partner_id,
|
| 2150 |
+
partner_name=partner.partner_name,
|
| 2151 |
+
organization=partner.organization,
|
| 2152 |
+
status=partner.status.value,
|
| 2153 |
+
trust_score=partner.trust_score,
|
| 2154 |
+
allowed_sectors=partner.allowed_sectors,
|
| 2155 |
+
allowed_tiers=partner.allowed_tiers,
|
| 2156 |
+
)
|
| 2157 |
+
|
| 2158 |
+
|
| 2159 |
+
@router.get("/partners/{partner_id}/trust-score")
|
| 2160 |
+
async def get_delegated_partner_trust_score(
|
| 2161 |
+
partner_id: str,
|
| 2162 |
+
):
|
| 2163 |
+
"""
|
| 2164 |
+
Get delegated certification partner trust score (Public Endpoint).
|
| 2165 |
+
|
| 2166 |
+
Returns trust score using formula:
|
| 2167 |
+
TrustScore = 1 - (RevokedCertificates / TotalCertificates)
|
| 2168 |
+
"""
|
| 2169 |
+
from certification_partners.delegated_certification import create_certification_engine
|
| 2170 |
+
|
| 2171 |
+
engine = create_certification_engine()
|
| 2172 |
+
trust_score = engine.compute_partner_trust_score(partner_id)
|
| 2173 |
+
|
| 2174 |
+
if trust_score == 0.0 and engine.get_partner(partner_id) is None:
|
| 2175 |
+
raise HTTPException(
|
| 2176 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 2177 |
+
detail=f"Partner {partner_id} not found"
|
| 2178 |
+
)
|
| 2179 |
+
|
| 2180 |
+
return {
|
| 2181 |
+
"partner_id": partner_id,
|
| 2182 |
+
"trust_score": trust_score,
|
| 2183 |
+
}
|
backend/benchmarking/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AegisLM Benchmarking Module
|
| 3 |
+
|
| 4 |
+
Provides benchmarking capabilities for evaluating LLM robustness:
|
| 5 |
+
- Baseline evaluation mode
|
| 6 |
+
- Adversarial evaluation mode
|
| 7 |
+
- Delta robustness computation
|
| 8 |
+
- Cross-model comparison
|
| 9 |
+
- Statistical reporting
|
| 10 |
+
- Benchmark artifact generation
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from backend.benchmarking.comparison import (
|
| 14 |
+
compare_models,
|
| 15 |
+
find_most_robust_model,
|
| 16 |
+
find_most_stable_model,
|
| 17 |
+
find_most_vulnerable_model,
|
| 18 |
+
generate_comparative_report,
|
| 19 |
+
generate_vulnerability_heatmap,
|
| 20 |
+
get_attack_type_vulnerability,
|
| 21 |
+
rank_models,
|
| 22 |
+
)
|
| 23 |
+
from backend.benchmarking.engine import (
|
| 24 |
+
BenchmarkEngine,
|
| 25 |
+
BenchmarkEvent,
|
| 26 |
+
get_benchmark_engine,
|
| 27 |
+
)
|
| 28 |
+
from backend.benchmarking.reporter import (
|
| 29 |
+
DEFAULT_BENCHMARK_DIR,
|
| 30 |
+
export_to_csv,
|
| 31 |
+
generate_benchmark_artifact,
|
| 32 |
+
generate_summary_report,
|
| 33 |
+
generate_text_report,
|
| 34 |
+
list_benchmarks,
|
| 35 |
+
load_benchmark_artifact,
|
| 36 |
+
)
|
| 37 |
+
from backend.benchmarking.schemas import (
|
| 38 |
+
BenchmarkMode,
|
| 39 |
+
BenchmarkPerformance,
|
| 40 |
+
BenchmarkResult,
|
| 41 |
+
BenchmarkStatus,
|
| 42 |
+
BenchmarkWeights,
|
| 43 |
+
EvaluationResult,
|
| 44 |
+
MetricDeltas,
|
| 45 |
+
ModelBenchmarkResult,
|
| 46 |
+
ModelMetrics,
|
| 47 |
+
ModelRanking,
|
| 48 |
+
StartBenchmarkRequest,
|
| 49 |
+
StartBenchmarkResponse,
|
| 50 |
+
VulnerabilityHeatmap,
|
| 51 |
+
VulnerabilityHeatmapCell,
|
| 52 |
+
)
|
| 53 |
+
from backend.benchmarking.statistics import (
|
| 54 |
+
MetricStatistics,
|
| 55 |
+
calculate_confidence_interval,
|
| 56 |
+
calculate_mean,
|
| 57 |
+
calculate_mean_with_ci,
|
| 58 |
+
calculate_paired_differences,
|
| 59 |
+
calculate_standard_deviation,
|
| 60 |
+
calculate_variance,
|
| 61 |
+
calculate_sample_std,
|
| 62 |
+
cohens_d,
|
| 63 |
+
calculate_vulnerability_consistency,
|
| 64 |
+
generate_summary_statistics,
|
| 65 |
+
paired_t_test,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
__all__ = [
|
| 70 |
+
# Comparison
|
| 71 |
+
"compare_models",
|
| 72 |
+
"find_most_robust_model",
|
| 73 |
+
"find_most_stable_model",
|
| 74 |
+
"find_most_vulnerable_model",
|
| 75 |
+
"generate_comparative_report",
|
| 76 |
+
"generate_vulnerability_heatmap",
|
| 77 |
+
"get_attack_type_vulnerability",
|
| 78 |
+
"rank_models",
|
| 79 |
+
# Engine
|
| 80 |
+
"BenchmarkEngine",
|
| 81 |
+
"BenchmarkEvent",
|
| 82 |
+
"get_benchmark_engine",
|
| 83 |
+
# Reporter
|
| 84 |
+
"DEFAULT_BENCHMARK_DIR",
|
| 85 |
+
"export_to_csv",
|
| 86 |
+
"generate_benchmark_artifact",
|
| 87 |
+
"generate_summary_report",
|
| 88 |
+
"generate_text_report",
|
| 89 |
+
"list_benchmarks",
|
| 90 |
+
"load_benchmark_artifact",
|
| 91 |
+
# Schemas
|
| 92 |
+
"BenchmarkMode",
|
| 93 |
+
"BenchmarkPerformance",
|
| 94 |
+
"BenchmarkResult",
|
| 95 |
+
"BenchmarkStatus",
|
| 96 |
+
"BenchmarkWeights",
|
| 97 |
+
"EvaluationResult",
|
| 98 |
+
"MetricDeltas",
|
| 99 |
+
"ModelBenchmarkResult",
|
| 100 |
+
"ModelMetrics",
|
| 101 |
+
"ModelRanking",
|
| 102 |
+
"StartBenchmarkRequest",
|
| 103 |
+
"StartBenchmarkResponse",
|
| 104 |
+
"VulnerabilityHeatmap",
|
| 105 |
+
"VulnerabilityHeatmapCell",
|
| 106 |
+
# Statistics
|
| 107 |
+
"MetricStatistics",
|
| 108 |
+
"calculate_confidence_interval",
|
| 109 |
+
"calculate_mean",
|
| 110 |
+
"calculate_mean_with_ci",
|
| 111 |
+
"calculate_paired_differences",
|
| 112 |
+
"calculate_standard_deviation",
|
| 113 |
+
"calculate_variance",
|
| 114 |
+
"calculate_sample_std",
|
| 115 |
+
"cohens_d",
|
| 116 |
+
"calculate_vulnerability_consistency",
|
| 117 |
+
"generate_summary_statistics",
|
| 118 |
+
"paired_t_test",
|
| 119 |
+
]
|
backend/benchmarking/comparison.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cross-Model Comparison Module
|
| 3 |
+
|
| 4 |
+
Provides cross-model comparison and ranking capabilities:
|
| 5 |
+
- Model ranking based on multiple metrics
|
| 6 |
+
- Vulnerability heatmap generation
|
| 7 |
+
- Comparative reporting
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from backend.benchmarking.schemas import (
|
| 13 |
+
ModelBenchmarkResult,
|
| 14 |
+
ModelRanking,
|
| 15 |
+
VulnerabilityHeatmap,
|
| 16 |
+
VulnerabilityHeatmapCell,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# =============================================================================
|
| 21 |
+
# Model Ranking
|
| 22 |
+
# =============================================================================
|
| 23 |
+
|
| 24 |
+
def rank_models(
|
| 25 |
+
results: List[ModelBenchmarkResult],
|
| 26 |
+
weights: Optional[Dict[str, float]] = None,
|
| 27 |
+
) -> List[ModelRanking]:
|
| 28 |
+
"""
|
| 29 |
+
Rank models based on multiple metrics.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
results: List of model benchmark results
|
| 33 |
+
weights: Optional weights for ranking (default: equal weights)
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
List of ModelRanking sorted by overall score (descending)
|
| 37 |
+
"""
|
| 38 |
+
if not results:
|
| 39 |
+
return []
|
| 40 |
+
|
| 41 |
+
if weights is None:
|
| 42 |
+
weights = {
|
| 43 |
+
"robustness": 0.40,
|
| 44 |
+
"hallucination_resilience": 0.20,
|
| 45 |
+
"bias_stability": 0.20,
|
| 46 |
+
"confidence_retention": 0.20,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
rankings = []
|
| 50 |
+
|
| 51 |
+
for result in results:
|
| 52 |
+
if not result.adversarial:
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
# Calculate hallucination resilience (inverse of hallucination delta)
|
| 56 |
+
# Higher resilience = lower hallucination increase under attack
|
| 57 |
+
hallucination_resilience = 1.0 - max(result.deltas.hallucination_delta, 0) if result.deltas else 0.5
|
| 58 |
+
|
| 59 |
+
# Calculate bias stability (inverse of bias delta)
|
| 60 |
+
bias_stability = 1.0 - max(result.deltas.bias_delta, 0) if result.deltas else 0.5
|
| 61 |
+
|
| 62 |
+
# Calculate confidence retention (inverse of confidence delta)
|
| 63 |
+
# Higher retention = confidence maintained under attack
|
| 64 |
+
confidence_retention = 1.0 - abs(result.deltas.confidence_delta) if result.deltas else 0.5
|
| 65 |
+
|
| 66 |
+
# Calculate overall score
|
| 67 |
+
robustness_score = result.adversarial_robustness or 0.0
|
| 68 |
+
overall_score = (
|
| 69 |
+
weights.get("robustness", 0.4) * robustness_score +
|
| 70 |
+
weights.get("hallucination_resilience", 0.2) * hallucination_resilience +
|
| 71 |
+
weights.get("bias_stability", 0.2) * bias_stability +
|
| 72 |
+
weights.get("confidence_retention", 0.2) * confidence_retention
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
rankings.append(ModelRanking(
|
| 76 |
+
model_name=result.model_name,
|
| 77 |
+
rank=0, # Will be set after sorting
|
| 78 |
+
robustness_score=robustness_score,
|
| 79 |
+
hallucination_resilience=hallucination_resilience,
|
| 80 |
+
bias_stability=bias_stability,
|
| 81 |
+
confidence_retention=confidence_retention,
|
| 82 |
+
overall_score=overall_score,
|
| 83 |
+
))
|
| 84 |
+
|
| 85 |
+
# Sort by overall score (descending)
|
| 86 |
+
rankings.sort(key=lambda x: x.overall_score, reverse=True)
|
| 87 |
+
|
| 88 |
+
# Assign ranks
|
| 89 |
+
for i, ranking in enumerate(rankings):
|
| 90 |
+
ranking.rank = i + 1
|
| 91 |
+
|
| 92 |
+
return rankings
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# =============================================================================
|
| 96 |
+
# Model Comparison
|
| 97 |
+
# =============================================================================
|
| 98 |
+
|
| 99 |
+
def compare_models(
|
| 100 |
+
model_a: ModelBenchmarkResult,
|
| 101 |
+
model_b: ModelBenchmarkResult,
|
| 102 |
+
) -> Dict[str, Any]:
|
| 103 |
+
"""
|
| 104 |
+
Compare two models and return detailed comparison.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
model_a: First model result
|
| 108 |
+
model_b: Second model result
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Dictionary with comparison results
|
| 112 |
+
"""
|
| 113 |
+
comparison = {
|
| 114 |
+
"model_a": model_a.model_name,
|
| 115 |
+
"model_b": model_b.model_name,
|
| 116 |
+
"robustness_comparison": {},
|
| 117 |
+
"metric_deltas_comparison": {},
|
| 118 |
+
"winner": None,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Compare robustness
|
| 122 |
+
if model_a.adversarial_robustness and model_b.adversarial_robustness:
|
| 123 |
+
rob_a = model_a.adversarial_robustness
|
| 124 |
+
rob_b = model_b.adversarial_robustness
|
| 125 |
+
comparison["robustness_comparison"] = {
|
| 126 |
+
"model_a_robustness": rob_a,
|
| 127 |
+
"model_b_robustness": rob_b,
|
| 128 |
+
"difference": rob_a - rob_b,
|
| 129 |
+
"winner": model_a.model_name if rob_a > rob_b else model_b.model_name if rob_b > rob_a else "tie",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
# Compare deltas (lower is better for deltas)
|
| 133 |
+
if model_a.deltas and model_b.deltas:
|
| 134 |
+
comparison["metric_deltas_comparison"] = {
|
| 135 |
+
"hallucination": {
|
| 136 |
+
"model_a": model_a.deltas.hallucination_delta,
|
| 137 |
+
"model_b": model_b.deltas.hallucination_delta,
|
| 138 |
+
"winner": _get_delta_winner(
|
| 139 |
+
model_a.deltas.hallucination_delta,
|
| 140 |
+
model_b.deltas.hallucination_delta,
|
| 141 |
+
lower_better=True,
|
| 142 |
+
),
|
| 143 |
+
},
|
| 144 |
+
"toxicity": {
|
| 145 |
+
"model_a": model_a.deltas.toxicity_delta,
|
| 146 |
+
"model_b": model_b.deltas.toxicity_delta,
|
| 147 |
+
"winner": _get_delta_winner(
|
| 148 |
+
model_a.deltas.toxicity_delta,
|
| 149 |
+
model_b.deltas.toxicity_delta,
|
| 150 |
+
lower_better=True,
|
| 151 |
+
),
|
| 152 |
+
},
|
| 153 |
+
"bias": {
|
| 154 |
+
"model_a": model_a.deltas.bias_delta,
|
| 155 |
+
"model_b": model_b.deltas.bias_delta,
|
| 156 |
+
"winner": _get_delta_winner(
|
| 157 |
+
model_a.deltas.bias_delta,
|
| 158 |
+
model_b.deltas.bias_delta,
|
| 159 |
+
lower_better=True,
|
| 160 |
+
),
|
| 161 |
+
},
|
| 162 |
+
"confidence": {
|
| 163 |
+
"model_a": model_a.deltas.confidence_delta,
|
| 164 |
+
"model_b": model_b.deltas.confidence_delta,
|
| 165 |
+
"winner": _get_delta_winner(
|
| 166 |
+
model_a.deltas.confidence_delta,
|
| 167 |
+
model_b.deltas.confidence_delta,
|
| 168 |
+
lower_better=True,
|
| 169 |
+
),
|
| 170 |
+
},
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
# Determine overall winner
|
| 174 |
+
score_a = model_a.adversarial_robustness or 0.0
|
| 175 |
+
score_b = model_b.adversarial_robustness or 0.0
|
| 176 |
+
|
| 177 |
+
if score_a > score_b:
|
| 178 |
+
comparison["winner"] = model_a.model_name
|
| 179 |
+
elif score_b > score_a:
|
| 180 |
+
comparison["winner"] = model_b.model_name
|
| 181 |
+
else:
|
| 182 |
+
comparison["winner"] = "tie"
|
| 183 |
+
|
| 184 |
+
return comparison
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _get_delta_winner(
|
| 188 |
+
delta_a: float,
|
| 189 |
+
delta_b: float,
|
| 190 |
+
lower_better: bool = True,
|
| 191 |
+
) -> str:
|
| 192 |
+
"""Get winner based on delta values."""
|
| 193 |
+
if lower_better:
|
| 194 |
+
if delta_a < delta_b:
|
| 195 |
+
return "model_a"
|
| 196 |
+
elif delta_b < delta_a:
|
| 197 |
+
return "model_b"
|
| 198 |
+
else:
|
| 199 |
+
return "tie"
|
| 200 |
+
else:
|
| 201 |
+
if delta_a > delta_b:
|
| 202 |
+
return "model_a"
|
| 203 |
+
elif delta_b > delta_a:
|
| 204 |
+
return "model_b"
|
| 205 |
+
else:
|
| 206 |
+
return "tie"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# =============================================================================
|
| 210 |
+
# Find Best/Worst Models
|
| 211 |
+
# =============================================================================
|
| 212 |
+
|
| 213 |
+
def find_most_robust_model(
|
| 214 |
+
results: List[ModelBenchmarkResult],
|
| 215 |
+
) -> Optional[ModelBenchmarkResult]:
|
| 216 |
+
"""
|
| 217 |
+
Find the model with highest adversarial robustness.
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
results: List of model benchmark results
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
ModelBenchmarkResult with highest robustness, or None if no results
|
| 224 |
+
"""
|
| 225 |
+
valid_results = [r for r in results if r.adversarial_robustness is not None]
|
| 226 |
+
|
| 227 |
+
if not valid_results:
|
| 228 |
+
return None
|
| 229 |
+
|
| 230 |
+
return max(valid_results, key=lambda x: x.adversarial_robustness)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def find_most_stable_model(
|
| 234 |
+
results: List[ModelBenchmarkResult],
|
| 235 |
+
) -> Optional[ModelBenchmarkResult]:
|
| 236 |
+
"""
|
| 237 |
+
Find the model with highest Robustness Stability Index (RSI).
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
results: List of model benchmark results
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
ModelBenchmarkResult with highest RSI, or None if no results
|
| 244 |
+
"""
|
| 245 |
+
valid_results = [r for r in results if r.robustness_stability_index is not None]
|
| 246 |
+
|
| 247 |
+
if not valid_results:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
return max(valid_results, key=lambda x: x.robustness_stability_index)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def find_most_vulnerable_model(
|
| 254 |
+
results: List[ModelBenchmarkResult],
|
| 255 |
+
) -> Optional[ModelBenchmarkResult]:
|
| 256 |
+
"""
|
| 257 |
+
Find the model with highest Vulnerability Index (VI).
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
results: List of model benchmark results
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
ModelBenchmarkResult with highest VI (most vulnerable), or None if no results
|
| 264 |
+
"""
|
| 265 |
+
valid_results = [r for r in results if r.vulnerability_index is not None]
|
| 266 |
+
|
| 267 |
+
if not valid_results:
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
return max(valid_results, key=lambda x: x.vulnerability_index)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# =============================================================================
|
| 274 |
+
# Vulnerability Heatmap
|
| 275 |
+
# =============================================================================
|
| 276 |
+
|
| 277 |
+
def generate_vulnerability_heatmap(
|
| 278 |
+
results: List[ModelBenchmarkResult],
|
| 279 |
+
attack_types: List[str],
|
| 280 |
+
) -> VulnerabilityHeatmap:
|
| 281 |
+
"""
|
| 282 |
+
Generate vulnerability heatmap matrix.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
results: List of model benchmark results
|
| 286 |
+
attack_types: List of attack types
|
| 287 |
+
|
| 288 |
+
Returns:
|
| 289 |
+
VulnerabilityHeatmap matrix
|
| 290 |
+
"""
|
| 291 |
+
metrics = ["hallucination", "toxicity", "bias", "confidence"]
|
| 292 |
+
cells = []
|
| 293 |
+
|
| 294 |
+
# For each attack type and metric combination
|
| 295 |
+
for attack_type in attack_types:
|
| 296 |
+
for metric in metrics:
|
| 297 |
+
# Calculate mean vulnerability for this attack-metric combination
|
| 298 |
+
# In a real implementation, this would filter by attack type
|
| 299 |
+
# For now, we aggregate across all results
|
| 300 |
+
values = []
|
| 301 |
+
|
| 302 |
+
for result in results:
|
| 303 |
+
if result.deltas:
|
| 304 |
+
delta_value = getattr(result.deltas, f"{metric}_delta", 0)
|
| 305 |
+
if delta_value is not None:
|
| 306 |
+
values.append(delta_value)
|
| 307 |
+
|
| 308 |
+
# Calculate mean
|
| 309 |
+
mean_value = sum(values) / len(values) if values else 0.0
|
| 310 |
+
|
| 311 |
+
cells.append(VulnerabilityHeatmapCell(
|
| 312 |
+
attack_type=attack_type,
|
| 313 |
+
metric=metric,
|
| 314 |
+
value=mean_value,
|
| 315 |
+
sample_count=len(values),
|
| 316 |
+
))
|
| 317 |
+
|
| 318 |
+
return VulnerabilityHeatmap(
|
| 319 |
+
rows=attack_types,
|
| 320 |
+
columns=metrics,
|
| 321 |
+
cells=cells,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def get_attack_type_vulnerability(
|
| 326 |
+
results: List[ModelBenchmarkResult],
|
| 327 |
+
attack_type: str,
|
| 328 |
+
) -> Dict[str, float]:
|
| 329 |
+
"""
|
| 330 |
+
Get vulnerability metrics for a specific attack type.
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
results: List of model benchmark results
|
| 334 |
+
attack_type: Attack type to analyze
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
Dictionary with vulnerability metrics
|
| 338 |
+
"""
|
| 339 |
+
# In a real implementation, this would filter by attack type
|
| 340 |
+
# For now, we aggregate across all results
|
| 341 |
+
hallucination_values = []
|
| 342 |
+
toxicity_values = []
|
| 343 |
+
bias_values = []
|
| 344 |
+
confidence_values = []
|
| 345 |
+
|
| 346 |
+
for result in results:
|
| 347 |
+
if result.deltas:
|
| 348 |
+
if result.deltas.hallucination_delta is not None:
|
| 349 |
+
hallucination_values.append(result.deltas.hallucination_delta)
|
| 350 |
+
if result.deltas.toxicity_delta is not None:
|
| 351 |
+
toxicity_values.append(result.deltas.toxicity_delta)
|
| 352 |
+
if result.deltas.bias_delta is not None:
|
| 353 |
+
bias_values.append(result.deltas.bias_delta)
|
| 354 |
+
if result.deltas.confidence_delta is not None:
|
| 355 |
+
confidence_values.append(result.deltas.confidence_delta)
|
| 356 |
+
|
| 357 |
+
def avg(lst: List[float]) -> float:
|
| 358 |
+
return sum(lst) / len(lst) if lst else 0.0
|
| 359 |
+
|
| 360 |
+
return {
|
| 361 |
+
"attack_type": attack_type,
|
| 362 |
+
"hallucination": avg(hallucination_values),
|
| 363 |
+
"toxicity": avg(toxicity_values),
|
| 364 |
+
"bias": avg(bias_values),
|
| 365 |
+
"confidence": avg(confidence_values),
|
| 366 |
+
"sample_count": len(results),
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# =============================================================================
|
| 371 |
+
# Comparative Report Generation
|
| 372 |
+
# =============================================================================
|
| 373 |
+
|
| 374 |
+
def generate_comparative_report(
|
| 375 |
+
results: List[ModelBenchmarkResult],
|
| 376 |
+
attack_types: Optional[List[str]] = None,
|
| 377 |
+
) -> Dict[str, Any]:
|
| 378 |
+
"""
|
| 379 |
+
Generate comprehensive comparative report.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
results: List of model benchmark results
|
| 383 |
+
attack_types: Optional list of attack types
|
| 384 |
+
|
| 385 |
+
Returns:
|
| 386 |
+
Dictionary with comparative analysis
|
| 387 |
+
"""
|
| 388 |
+
if attack_types is None:
|
| 389 |
+
attack_types = ["jailbreak", "injection", "bias_trigger"]
|
| 390 |
+
|
| 391 |
+
report = {
|
| 392 |
+
"total_models": len(results),
|
| 393 |
+
"models_analyzed": [r.model_name for r in results],
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
# Find best/worst
|
| 397 |
+
most_robust = find_most_robust_model(results)
|
| 398 |
+
most_stable = find_most_stable_model(results)
|
| 399 |
+
most_vulnerable = find_most_vulnerable_model(results)
|
| 400 |
+
|
| 401 |
+
if most_robust:
|
| 402 |
+
report["most_robust"] = {
|
| 403 |
+
"model": most_robust.model_name,
|
| 404 |
+
"robustness": most_robust.adversarial_robustness,
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
if most_stable:
|
| 408 |
+
report["most_stable"] = {
|
| 409 |
+
"model": most_stable.model_name,
|
| 410 |
+
"rsi": most_stable.robustness_stability_index,
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
if most_vulnerable:
|
| 414 |
+
report["most_vulnerable"] = {
|
| 415 |
+
"model": most_vulnerable.model_name,
|
| 416 |
+
"vi": most_vulnerable.vulnerability_index,
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
# Generate rankings
|
| 420 |
+
rankings = rank_models(results)
|
| 421 |
+
report["rankings"] = [
|
| 422 |
+
{
|
| 423 |
+
"rank": r.rank,
|
| 424 |
+
"model": r.model_name,
|
| 425 |
+
"overall_score": r.overall_score,
|
| 426 |
+
"robustness": r.robustness_score,
|
| 427 |
+
}
|
| 428 |
+
for r in rankings
|
| 429 |
+
]
|
| 430 |
+
|
| 431 |
+
# Generate vulnerability heatmap
|
| 432 |
+
heatmap = generate_vulnerability_heatmap(results, attack_types)
|
| 433 |
+
report["vulnerability_heatmap"] = {
|
| 434 |
+
"rows": heatmap.rows,
|
| 435 |
+
"columns": heatmap.columns,
|
| 436 |
+
"cells": [
|
| 437 |
+
{
|
| 438 |
+
"attack_type": cell.attack_type,
|
| 439 |
+
"metric": cell.metric,
|
| 440 |
+
"value": cell.value,
|
| 441 |
+
}
|
| 442 |
+
for cell in heatmap.cells
|
| 443 |
+
],
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
# Pairwise comparisons
|
| 447 |
+
if len(results) >= 2:
|
| 448 |
+
comparisons = []
|
| 449 |
+
for i in range(len(results)):
|
| 450 |
+
for j in range(i + 1, len(results)):
|
| 451 |
+
comparison = compare_models(results[i], results[j])
|
| 452 |
+
comparisons.append(comparison)
|
| 453 |
+
|
| 454 |
+
report["pairwise_comparisons"] = comparisons
|
| 455 |
+
|
| 456 |
+
return report
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
__all__ = [
|
| 460 |
+
"compare_models",
|
| 461 |
+
"find_most_robust_model",
|
| 462 |
+
"find_most_stable_model",
|
| 463 |
+
"find_most_vulnerable_model",
|
| 464 |
+
"generate_comparative_report",
|
| 465 |
+
"generate_vulnerability_heatmap",
|
| 466 |
+
"get_attack_type_vulnerability",
|
| 467 |
+
"rank_models",
|
| 468 |
+
]
|
backend/benchmarking/engine.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmarking Engine
|
| 3 |
+
|
| 4 |
+
Main orchestration for the AegisLM Benchmarking Engine:
|
| 5 |
+
- Baseline evaluation mode
|
| 6 |
+
- Adversarial evaluation mode
|
| 7 |
+
- Delta robustness computation
|
| 8 |
+
- Cross-model comparison
|
| 9 |
+
- Statistical reporting
|
| 10 |
+
- Benchmark artifact generation
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import time
|
| 15 |
+
import uuid
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
from uuid import UUID
|
| 20 |
+
|
| 21 |
+
from backend.benchmarking.comparison import (
|
| 22 |
+
generate_comparative_report,
|
| 23 |
+
generate_vulnerability_heatmap,
|
| 24 |
+
rank_models,
|
| 25 |
+
)
|
| 26 |
+
from backend.benchmarking.reporter import (
|
| 27 |
+
generate_benchmark_artifact,
|
| 28 |
+
generate_text_report,
|
| 29 |
+
)
|
| 30 |
+
from backend.benchmarking.schemas import (
|
| 31 |
+
BenchmarkConfig,
|
| 32 |
+
BenchmarkMode,
|
| 33 |
+
BenchmarkPerformance,
|
| 34 |
+
BenchmarkResult,
|
| 35 |
+
BenchmarkStatus,
|
| 36 |
+
BenchmarkWeights,
|
| 37 |
+
EvaluationResult,
|
| 38 |
+
MetricDeltas,
|
| 39 |
+
ModelBenchmarkResult,
|
| 40 |
+
ModelMetrics,
|
| 41 |
+
StartBenchmarkRequest,
|
| 42 |
+
)
|
| 43 |
+
from backend.benchmarking.statistics import (
|
| 44 |
+
MetricStatistics,
|
| 45 |
+
calculate_vulnerability_consistency,
|
| 46 |
+
)
|
| 47 |
+
from backend.core.config import settings
|
| 48 |
+
from backend.core.orchestrator import (
|
| 49 |
+
EvaluationInput,
|
| 50 |
+
EvaluationOrchestrator,
|
| 51 |
+
RunStatus,
|
| 52 |
+
)
|
| 53 |
+
from backend.logging.logger import get_logger
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# =============================================================================
|
| 57 |
+
# Benchmark Events
|
| 58 |
+
# =============================================================================
|
| 59 |
+
|
| 60 |
+
class BenchmarkEvent(str, Enum):
|
| 61 |
+
"""Observability events for benchmarking."""
|
| 62 |
+
BENCHMARK_STARTED = "BENCHMARK_STARTED"
|
| 63 |
+
BENCHMARK_COMPLETED = "BENCHMARK_COMPLETED"
|
| 64 |
+
BENCHMARK_FAILED = "BENCHMARK_FAILED"
|
| 65 |
+
MODEL_EVALUATION_STARTED = "MODEL_EVALUATION_STARTED"
|
| 66 |
+
MODEL_EVALUATION_COMPLETED = "MODEL_EVALUATION_COMPLETED"
|
| 67 |
+
BASELINE_COMPLETED = "BASELINE_COMPLETED"
|
| 68 |
+
ADVERSARIAL_COMPLETED = "ADVERSARIAL_COMPLETED"
|
| 69 |
+
DELTA_COMPUTED = "DELTA_COMPUTED"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# =============================================================================
|
| 73 |
+
# Benchmark Engine
|
| 74 |
+
# =============================================================================
|
| 75 |
+
|
| 76 |
+
class BenchmarkEngine:
|
| 77 |
+
"""
|
| 78 |
+
Main benchmarking engine for AegisLM.
|
| 79 |
+
|
| 80 |
+
Coordinates:
|
| 81 |
+
- Baseline evaluation (no attacks)
|
| 82 |
+
- Adversarial evaluation (full pipeline)
|
| 83 |
+
- Delta robustness computation
|
| 84 |
+
- Cross-model comparison
|
| 85 |
+
- Artifact generation
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __init__(self):
|
| 89 |
+
self.logger = get_logger(__name__)
|
| 90 |
+
self._orchestrator = EvaluationOrchestrator()
|
| 91 |
+
self._active_benchmarks: Dict[str, asyncio.Task] = {}
|
| 92 |
+
|
| 93 |
+
def _log_event(
|
| 94 |
+
self,
|
| 95 |
+
event: BenchmarkEvent,
|
| 96 |
+
benchmark_id: str,
|
| 97 |
+
**kwargs: Any
|
| 98 |
+
) -> None:
|
| 99 |
+
"""Log benchmark event."""
|
| 100 |
+
log_data = {
|
| 101 |
+
"event": event.value,
|
| 102 |
+
"benchmark_id": benchmark_id,
|
| 103 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 104 |
+
}
|
| 105 |
+
log_data.update(kwargs)
|
| 106 |
+
|
| 107 |
+
if event in [BenchmarkEvent.BENCHMARK_STARTED, BenchmarkEvent.BENCHMARK_COMPLETED]:
|
| 108 |
+
self.logger.info("Benchmark event", **log_data)
|
| 109 |
+
elif event in [BenchmarkEvent.BENCHMARK_FAILED]:
|
| 110 |
+
self.logger.error("Benchmark event", **log_data)
|
| 111 |
+
else:
|
| 112 |
+
self.logger.debug("Benchmark event", **log_data)
|
| 113 |
+
|
| 114 |
+
async def start_benchmark(
|
| 115 |
+
self,
|
| 116 |
+
request: StartBenchmarkRequest,
|
| 117 |
+
) -> UUID:
|
| 118 |
+
"""
|
| 119 |
+
Start a new benchmark run.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
request: Benchmark configuration
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Benchmark ID
|
| 126 |
+
"""
|
| 127 |
+
benchmark_id = uuid.uuid4()
|
| 128 |
+
benchmark_id_str = str(benchmark_id)
|
| 129 |
+
|
| 130 |
+
self.logger.info(
|
| 131 |
+
"Starting benchmark",
|
| 132 |
+
benchmark_id=benchmark_id_str,
|
| 133 |
+
models=request.models,
|
| 134 |
+
dataset=request.dataset_name,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Create benchmark config
|
| 138 |
+
weights = request.weights or BenchmarkWeights()
|
| 139 |
+
config = BenchmarkConfig(
|
| 140 |
+
benchmark_id=benchmark_id,
|
| 141 |
+
models=request.models,
|
| 142 |
+
dataset_name=request.dataset_name,
|
| 143 |
+
dataset_version=request.dataset_version,
|
| 144 |
+
attack_enabled=request.attack_enabled,
|
| 145 |
+
mutation_depth=request.mutation_depth,
|
| 146 |
+
weights=weights,
|
| 147 |
+
max_concurrency=request.max_concurrency,
|
| 148 |
+
max_samples=request.max_samples,
|
| 149 |
+
enable_baseline=request.enable_baseline,
|
| 150 |
+
enable_adversarial=request.enable_adversarial,
|
| 151 |
+
attack_types=request.attack_types or ["jailbreak"],
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# Validate config
|
| 155 |
+
config.validate_config()
|
| 156 |
+
|
| 157 |
+
# Start async execution
|
| 158 |
+
task = asyncio.create_task(
|
| 159 |
+
self._execute_benchmark(config)
|
| 160 |
+
)
|
| 161 |
+
self._active_benchmarks[benchmark_id_str] = task
|
| 162 |
+
|
| 163 |
+
return benchmark_id
|
| 164 |
+
|
| 165 |
+
async def _execute_benchmark(
|
| 166 |
+
self,
|
| 167 |
+
config: BenchmarkConfig,
|
| 168 |
+
) -> BenchmarkResult:
|
| 169 |
+
"""
|
| 170 |
+
Execute the benchmark asynchronously.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
config: Benchmark configuration
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Complete benchmark result
|
| 177 |
+
"""
|
| 178 |
+
benchmark_id = config.benchmark_id
|
| 179 |
+
benchmark_id_str = str(benchmark_id)
|
| 180 |
+
start_time = datetime.utcnow()
|
| 181 |
+
|
| 182 |
+
# Initialize result
|
| 183 |
+
result = BenchmarkResult(
|
| 184 |
+
benchmark_id=benchmark_id,
|
| 185 |
+
dataset_name=config.dataset_name,
|
| 186 |
+
dataset_version=config.dataset_version,
|
| 187 |
+
models=config.models,
|
| 188 |
+
status=BenchmarkStatus.RUNNING,
|
| 189 |
+
results=[],
|
| 190 |
+
performance=BenchmarkPerformance(),
|
| 191 |
+
started_at=start_time,
|
| 192 |
+
config=config.model_dump(),
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Log start
|
| 196 |
+
self._log_event(
|
| 197 |
+
BenchmarkEvent.BENCHMARK_STARTED,
|
| 198 |
+
benchmark_id_str,
|
| 199 |
+
models=config.models,
|
| 200 |
+
dataset=config.dataset_name,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
# Evaluate each model
|
| 205 |
+
for model_name in config.models:
|
| 206 |
+
self._log_event(
|
| 207 |
+
BenchmarkEvent.MODEL_EVALUATION_STARTED,
|
| 208 |
+
benchmark_id_str,
|
| 209 |
+
model=model_name,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
model_start_time = time.time()
|
| 213 |
+
|
| 214 |
+
# Evaluate model
|
| 215 |
+
model_result = await self._evaluate_model(
|
| 216 |
+
config=config,
|
| 217 |
+
model_name=model_name,
|
| 218 |
+
benchmark_id=benchmark_id_str,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
model_time = time.time() - model_start_time
|
| 222 |
+
|
| 223 |
+
# Update performance tracking
|
| 224 |
+
result.performance.time_per_model_seconds[model_name] = model_time
|
| 225 |
+
result.performance.sample_counts[model_name] = (
|
| 226 |
+
model_result.adversarial.sample_count if model_result.adversarial else 0
|
| 227 |
+
)
|
| 228 |
+
result.performance.failure_rates[model_name] = (
|
| 229 |
+
model_result.adversarial.failure_rate if model_result.adversarial else 1.0
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
result.results.append(model_result)
|
| 233 |
+
|
| 234 |
+
self._log_event(
|
| 235 |
+
BenchmarkEvent.MODEL_EVALUATION_COMPLETED,
|
| 236 |
+
benchmark_id_str,
|
| 237 |
+
model=model_name,
|
| 238 |
+
time_seconds=model_time,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Compute rankings (if multiple models)
|
| 242 |
+
if len(config.models) > 1:
|
| 243 |
+
result.rankings = rank_models(result.results)
|
| 244 |
+
|
| 245 |
+
# Generate vulnerability heatmap
|
| 246 |
+
result.vulnerability_heatmap = generate_vulnerability_heatmap(
|
| 247 |
+
result.results,
|
| 248 |
+
config.attack_types,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# Mark as completed
|
| 252 |
+
result.status = BenchmarkStatus.COMPLETED
|
| 253 |
+
result.completed_at = datetime.utcnow()
|
| 254 |
+
|
| 255 |
+
# Generate artifact
|
| 256 |
+
artifact_path = generate_benchmark_artifact(result)
|
| 257 |
+
self.logger.info(
|
| 258 |
+
"Benchmark artifact saved",
|
| 259 |
+
benchmark_id=benchmark_id_str,
|
| 260 |
+
path=artifact_path,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# Log completion
|
| 264 |
+
self._log_event(
|
| 265 |
+
BenchmarkEvent.BENCHMARK_COMPLETED,
|
| 266 |
+
benchmark_id_str,
|
| 267 |
+
models=config.models,
|
| 268 |
+
completed_at=result.completed_at.isoformat(),
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
except Exception as e:
|
| 272 |
+
result.status = BenchmarkStatus.FAILED
|
| 273 |
+
result.error = str(e)
|
| 274 |
+
result.completed_at = datetime.utcnow()
|
| 275 |
+
|
| 276 |
+
self.logger.error(
|
| 277 |
+
"Benchmark failed",
|
| 278 |
+
benchmark_id=benchmark_id_str,
|
| 279 |
+
error=str(e),
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
self._log_event(
|
| 283 |
+
BenchmarkEvent.BENCHMARK_FAILED,
|
| 284 |
+
benchmark_id_str,
|
| 285 |
+
error=str(e),
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
finally:
|
| 289 |
+
# Clean up active benchmark
|
| 290 |
+
self._active_benchmarks.pop(benchmark_id_str, None)
|
| 291 |
+
|
| 292 |
+
return result
|
| 293 |
+
|
| 294 |
+
async def _evaluate_model(
|
| 295 |
+
self,
|
| 296 |
+
config: BenchmarkConfig,
|
| 297 |
+
model_name: str,
|
| 298 |
+
benchmark_id: str,
|
| 299 |
+
) -> ModelBenchmarkResult:
|
| 300 |
+
"""
|
| 301 |
+
Evaluate a single model.
|
| 302 |
+
|
| 303 |
+
Args:
|
| 304 |
+
config: Benchmark configuration
|
| 305 |
+
model_name: Name of the model to evaluate
|
| 306 |
+
benchmark_id: Benchmark ID for logging
|
| 307 |
+
|
| 308 |
+
Returns:
|
| 309 |
+
Complete benchmark result for the model
|
| 310 |
+
"""
|
| 311 |
+
model_result = ModelBenchmarkResult(model_name=model_name)
|
| 312 |
+
|
| 313 |
+
# Create sampling config if max_samples is set
|
| 314 |
+
sampling_config = None
|
| 315 |
+
if config.max_samples:
|
| 316 |
+
sampling_config = {
|
| 317 |
+
"method": "random",
|
| 318 |
+
"sample_size": config.max_samples,
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
# Run baseline evaluation
|
| 322 |
+
if config.enable_baseline:
|
| 323 |
+
baseline_result = await self._run_evaluation(
|
| 324 |
+
model_name=model_name,
|
| 325 |
+
config=config,
|
| 326 |
+
mode=BenchmarkMode.BASELINE,
|
| 327 |
+
attack_enabled=False,
|
| 328 |
+
benchmark_id=benchmark_id,
|
| 329 |
+
sampling_config=sampling_config,
|
| 330 |
+
)
|
| 331 |
+
model_result.baseline = baseline_result
|
| 332 |
+
model_result.baseline_robustness = baseline_result.metrics.robustness
|
| 333 |
+
|
| 334 |
+
self._log_event(
|
| 335 |
+
BenchmarkEvent.BASELINE_COMPLETED,
|
| 336 |
+
benchmark_id,
|
| 337 |
+
model=model_name,
|
| 338 |
+
robustness=model_result.baseline_robustness,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
# Run adversarial evaluation
|
| 342 |
+
if config.enable_adversarial:
|
| 343 |
+
adversarial_result = await self._run_evaluation(
|
| 344 |
+
model_name=model_name,
|
| 345 |
+
config=config,
|
| 346 |
+
mode=BenchmarkMode.ADVERSARIAL,
|
| 347 |
+
attack_enabled=config.attack_enabled,
|
| 348 |
+
benchmark_id=benchmark_id,
|
| 349 |
+
sampling_config=sampling_config,
|
| 350 |
+
)
|
| 351 |
+
model_result.adversarial = adversarial_result
|
| 352 |
+
model_result.adversarial_robustness = adversarial_result.metrics.robustness
|
| 353 |
+
|
| 354 |
+
self._log_event(
|
| 355 |
+
BenchmarkEvent.ADVERSARIAL_COMPLETED,
|
| 356 |
+
benchmark_id,
|
| 357 |
+
model=model_name,
|
| 358 |
+
robustness=model_result.adversarial_robustness,
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Compute deltas and derived metrics
|
| 362 |
+
if model_result.baseline and model_result.adversarial:
|
| 363 |
+
model_result.deltas = self._compute_deltas(
|
| 364 |
+
baseline=model_result.baseline,
|
| 365 |
+
adversarial=model_result.adversarial,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Compute delta robustness
|
| 369 |
+
# ΔR = R_base - R_adv
|
| 370 |
+
model_result.delta_robustness = (
|
| 371 |
+
model_result.baseline_robustness - model_result.adversarial_robustness
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
# Compute Robustness Stability Index (RSI)
|
| 375 |
+
# RSI = R_adv / R_base
|
| 376 |
+
if model_result.baseline_robustness and model_result.baseline_robustness > 0:
|
| 377 |
+
model_result.robustness_stability_index = (
|
| 378 |
+
model_result.adversarial_robustness / model_result.baseline_robustness
|
| 379 |
+
)
|
| 380 |
+
else:
|
| 381 |
+
model_result.robustness_stability_index = 0.0
|
| 382 |
+
|
| 383 |
+
# Compute Vulnerability Index (VI)
|
| 384 |
+
# VI = delta_R / R_base
|
| 385 |
+
if model_result.baseline_robustness and model_result.baseline_robustness > 0:
|
| 386 |
+
model_result.vulnerability_index = (
|
| 387 |
+
model_result.delta_robustness / model_result.baseline_robustness
|
| 388 |
+
)
|
| 389 |
+
else:
|
| 390 |
+
model_result.vulnerability_index = 0.0
|
| 391 |
+
|
| 392 |
+
self._log_event(
|
| 393 |
+
BenchmarkEvent.DELTA_COMPUTED,
|
| 394 |
+
benchmark_id,
|
| 395 |
+
model=model_name,
|
| 396 |
+
delta_robustness=model_result.delta_robustness,
|
| 397 |
+
rsi=model_result.robustness_stability_index,
|
| 398 |
+
vi=model_result.vulnerability_index,
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
return model_result
|
| 402 |
+
|
| 403 |
+
async def _run_evaluation(
|
| 404 |
+
self,
|
| 405 |
+
model_name: str,
|
| 406 |
+
config: BenchmarkConfig,
|
| 407 |
+
mode: BenchmarkMode,
|
| 408 |
+
attack_enabled: bool,
|
| 409 |
+
benchmark_id: str,
|
| 410 |
+
sampling_config: Optional[Dict[str, Any]] = None,
|
| 411 |
+
) -> EvaluationResult:
|
| 412 |
+
"""
|
| 413 |
+
Run a single evaluation (baseline or adversarial).
|
| 414 |
+
|
| 415 |
+
Args:
|
| 416 |
+
model_name: Model to evaluate
|
| 417 |
+
config: Benchmark config
|
| 418 |
+
mode: Evaluation mode
|
| 419 |
+
attack_enabled: Whether to enable attacks
|
| 420 |
+
benchmark_id: Benchmark ID
|
| 421 |
+
sampling_config: Optional sampling config
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
Evaluation result
|
| 425 |
+
"""
|
| 426 |
+
# Create evaluation input
|
| 427 |
+
eval_input = EvaluationInput(
|
| 428 |
+
model_name=model_name,
|
| 429 |
+
dataset_name=config.dataset_name,
|
| 430 |
+
dataset_version=config.dataset_version,
|
| 431 |
+
weights={
|
| 432 |
+
"hallucination": config.weights.hallucination,
|
| 433 |
+
"toxicity": config.weights.toxicity,
|
| 434 |
+
"bias": config.weights.bias,
|
| 435 |
+
"confidence": config.weights.confidence,
|
| 436 |
+
},
|
| 437 |
+
mutation_depth=config.mutation_depth if attack_enabled else 0,
|
| 438 |
+
attack_types=config.attack_types if attack_enabled else [],
|
| 439 |
+
max_concurrency=config.max_concurrency,
|
| 440 |
+
sampling_config=sampling_config,
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
# Run evaluation using orchestrator
|
| 444 |
+
output = await self._orchestrator.start_run(eval_input)
|
| 445 |
+
|
| 446 |
+
# Wait for completion
|
| 447 |
+
run_id = output.run_id
|
| 448 |
+
|
| 449 |
+
# Poll for completion (in production, this would be async callback)
|
| 450 |
+
max_wait = 300 # 5 minutes
|
| 451 |
+
waited = 0
|
| 452 |
+
poll_interval = 1
|
| 453 |
+
|
| 454 |
+
while waited < max_wait:
|
| 455 |
+
status = await self._orchestrator.get_run_status(run_id)
|
| 456 |
+
|
| 457 |
+
if status and status.status in [RunStatus.COMPLETED, RunStatus.FAILED]:
|
| 458 |
+
break
|
| 459 |
+
|
| 460 |
+
await asyncio.sleep(poll_interval)
|
| 461 |
+
waited += poll_interval
|
| 462 |
+
|
| 463 |
+
# Get final status
|
| 464 |
+
final_status = await self._orchestrator.get_run_status(run_id)
|
| 465 |
+
|
| 466 |
+
if final_status and final_status.status == RunStatus.COMPLETED:
|
| 467 |
+
# Extract metrics from output
|
| 468 |
+
metrics = ModelMetrics(
|
| 469 |
+
hallucination=final_status.metrics.get("hallucination", 0.5),
|
| 470 |
+
toxicity=final_status.metrics.get("toxicity", 0.5),
|
| 471 |
+
bias=final_status.metrics.get("bias", 0.5),
|
| 472 |
+
confidence=final_status.metrics.get("confidence", 0.5),
|
| 473 |
+
robustness=final_status.metrics.get("robustness", 0.5),
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# Get standard deviations if available
|
| 477 |
+
if final_status.metrics:
|
| 478 |
+
metrics.std_hallucination = final_status.metrics.get("std_hallucination")
|
| 479 |
+
metrics.std_toxicity = final_status.metrics.get("std_toxicity")
|
| 480 |
+
metrics.std_bias = final_status.metrics.get("std_bias")
|
| 481 |
+
metrics.std_confidence = final_status.metrics.get("std_confidence")
|
| 482 |
+
|
| 483 |
+
return EvaluationResult(
|
| 484 |
+
model_name=model_name,
|
| 485 |
+
mode=mode,
|
| 486 |
+
metrics=metrics,
|
| 487 |
+
sample_count=final_status.metrics.get("total_samples", 0),
|
| 488 |
+
failure_rate=final_status.metrics.get("failed_samples", 0) / max(final_status.metrics.get("total_samples", 1), 1),
|
| 489 |
+
mean_latency_ms=final_status.performance.get("mean_latency_ms"),
|
| 490 |
+
total_time_seconds=final_status.performance.get("total_time_seconds"),
|
| 491 |
+
)
|
| 492 |
+
else:
|
| 493 |
+
# Return default result on failure
|
| 494 |
+
return EvaluationResult(
|
| 495 |
+
model_name=model_name,
|
| 496 |
+
mode=mode,
|
| 497 |
+
metrics=ModelMetrics(
|
| 498 |
+
hallucination=0.5,
|
| 499 |
+
toxicity=0.5,
|
| 500 |
+
bias=0.5,
|
| 501 |
+
confidence=0.5,
|
| 502 |
+
robustness=0.5,
|
| 503 |
+
),
|
| 504 |
+
sample_count=0,
|
| 505 |
+
failure_rate=1.0,
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
def _compute_deltas(
|
| 509 |
+
self,
|
| 510 |
+
baseline: EvaluationResult,
|
| 511 |
+
adversarial: EvaluationResult,
|
| 512 |
+
) -> MetricDeltas:
|
| 513 |
+
"""
|
| 514 |
+
Compute deltas between baseline and adversarial.
|
| 515 |
+
|
| 516 |
+
Args:
|
| 517 |
+
baseline: Baseline evaluation result
|
| 518 |
+
adversarial: Adversarial evaluation result
|
| 519 |
+
|
| 520 |
+
Returns:
|
| 521 |
+
MetricDeltas with computed differences
|
| 522 |
+
"""
|
| 523 |
+
return MetricDeltas(
|
| 524 |
+
hallucination_delta=adversarial.metrics.hallucination - baseline.metrics.hallucination,
|
| 525 |
+
toxicity_delta=adversarial.metrics.toxicity - baseline.metrics.toxicity,
|
| 526 |
+
bias_delta=adversarial.metrics.bias - baseline.metrics.bias,
|
| 527 |
+
confidence_delta=adversarial.metrics.confidence - baseline.metrics.confidence,
|
| 528 |
+
robustness_delta=baseline.metrics.robustness - adversarial.metrics.robustness,
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
async def get_benchmark_status(
|
| 532 |
+
self,
|
| 533 |
+
benchmark_id: str,
|
| 534 |
+
) -> Optional[BenchmarkResult]:
|
| 535 |
+
"""
|
| 536 |
+
Get status of a benchmark.
|
| 537 |
+
|
| 538 |
+
Args:
|
| 539 |
+
benchmark_id: Benchmark ID
|
| 540 |
+
|
| 541 |
+
Returns:
|
| 542 |
+
Benchmark result if found, None otherwise
|
| 543 |
+
"""
|
| 544 |
+
# Check if benchmark is active
|
| 545 |
+
if benchmark_id in self._active_benchmarks:
|
| 546 |
+
task = self._active_benchmarks[benchmark_id]
|
| 547 |
+
|
| 548 |
+
if not task.done():
|
| 549 |
+
# Benchmark is still running
|
| 550 |
+
# For now, return a partial result
|
| 551 |
+
return BenchmarkResult(
|
| 552 |
+
benchmark_id=UUID(benchmark_id),
|
| 553 |
+
dataset_name="",
|
| 554 |
+
dataset_version="",
|
| 555 |
+
models=[],
|
| 556 |
+
status=BenchmarkStatus.RUNNING,
|
| 557 |
+
results=[],
|
| 558 |
+
performance=BenchmarkPerformance(),
|
| 559 |
+
started_at=datetime.utcnow(),
|
| 560 |
+
)
|
| 561 |
+
else:
|
| 562 |
+
# Benchmark completed, get result
|
| 563 |
+
return await task
|
| 564 |
+
|
| 565 |
+
# Try to load from artifact
|
| 566 |
+
from backend.benchmarking.reporter import load_benchmark_artifact
|
| 567 |
+
|
| 568 |
+
artifact = load_benchmark_artifact(benchmark_id)
|
| 569 |
+
|
| 570 |
+
if artifact:
|
| 571 |
+
# Reconstruct BenchmarkResult from artifact
|
| 572 |
+
# For simplicity, just return None - in production, parse the artifact
|
| 573 |
+
pass
|
| 574 |
+
|
| 575 |
+
return None
|
| 576 |
+
|
| 577 |
+
async def cancel_benchmark(
|
| 578 |
+
self,
|
| 579 |
+
benchmark_id: str,
|
| 580 |
+
) -> bool:
|
| 581 |
+
"""
|
| 582 |
+
Cancel a running benchmark.
|
| 583 |
+
|
| 584 |
+
Args:
|
| 585 |
+
benchmark_id: Benchmark ID
|
| 586 |
+
|
| 587 |
+
Returns:
|
| 588 |
+
True if cancelled, False otherwise
|
| 589 |
+
"""
|
| 590 |
+
if benchmark_id in self._active_benchmarks:
|
| 591 |
+
task = self._active_benchmarks[benchmark_id]
|
| 592 |
+
task.cancel()
|
| 593 |
+
|
| 594 |
+
try:
|
| 595 |
+
await task
|
| 596 |
+
except asyncio.CancelledError:
|
| 597 |
+
pass
|
| 598 |
+
|
| 599 |
+
self.logger.info("Benchmark cancelled", benchmark_id=benchmark_id)
|
| 600 |
+
return True
|
| 601 |
+
|
| 602 |
+
return False
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
# =============================================================================
|
| 606 |
+
# Global Instance
|
| 607 |
+
# =============================================================================
|
| 608 |
+
|
| 609 |
+
_benchmark_engine: Optional[BenchmarkEngine] = None
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def get_benchmark_engine() -> BenchmarkEngine:
|
| 613 |
+
"""
|
| 614 |
+
Get the global benchmark engine instance.
|
| 615 |
+
|
| 616 |
+
Returns:
|
| 617 |
+
BenchmarkEngine singleton
|
| 618 |
+
"""
|
| 619 |
+
global _benchmark_engine
|
| 620 |
+
if _benchmark_engine is None:
|
| 621 |
+
_benchmark_engine = BenchmarkEngine()
|
| 622 |
+
return _benchmark_engine
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
__all__ = [
|
| 626 |
+
"BenchmarkEngine",
|
| 627 |
+
"BenchmarkEvent",
|
| 628 |
+
"get_benchmark_engine",
|
| 629 |
+
]
|
backend/benchmarking/reporter.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmark Reporter Module
|
| 3 |
+
|
| 4 |
+
Handles benchmark artifact generation and reporting:
|
| 5 |
+
- JSON artifact structure
|
| 6 |
+
- GSS-1 Evaluation Artifact generation
|
| 7 |
+
- Report generation
|
| 8 |
+
- File I/O operations
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from backend.benchmarking.schemas import (
|
| 17 |
+
BenchmarkResult,
|
| 18 |
+
BenchmarkStatus,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# GSS-1 imports for standardized evaluation artifacts
|
| 22 |
+
from governance.standards import (
|
| 23 |
+
EvaluationArtifact,
|
| 24 |
+
GSSCalculator,
|
| 25 |
+
RiskClassifier,
|
| 26 |
+
calculate_confidence_interval,
|
| 27 |
+
DEFAULT_WEIGHTS,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# =============================================================================
|
| 32 |
+
# Configuration
|
| 33 |
+
# =============================================================================
|
| 34 |
+
|
| 35 |
+
DEFAULT_BENCHMARK_DIR = "experiments/benchmarks"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# GSS-1 Artifact Generation
|
| 40 |
+
# =============================================================================
|
| 41 |
+
|
| 42 |
+
def generate_gss1_artifact(
|
| 43 |
+
model_name: str,
|
| 44 |
+
model_version: str,
|
| 45 |
+
H: float,
|
| 46 |
+
T: float,
|
| 47 |
+
B: float,
|
| 48 |
+
C: float,
|
| 49 |
+
R: float,
|
| 50 |
+
RSI: Optional[float] = None,
|
| 51 |
+
sample_size: int = 0,
|
| 52 |
+
dataset_hash: str = "",
|
| 53 |
+
config_hash: str = "",
|
| 54 |
+
policy_version: str = "",
|
| 55 |
+
random_seed: int = 0,
|
| 56 |
+
scoring_weights: Optional[Dict[str, float]] = None,
|
| 57 |
+
) -> EvaluationArtifact:
|
| 58 |
+
"""
|
| 59 |
+
Generate a GSS-1 compliant evaluation artifact.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
model_name: Name of the model
|
| 63 |
+
model_version: Version of the model
|
| 64 |
+
H: Hallucination score
|
| 65 |
+
T: Toxicity score
|
| 66 |
+
B: Bias score
|
| 67 |
+
C: Confidence score
|
| 68 |
+
R: Robustness score
|
| 69 |
+
RSI: Robustness Stability Index
|
| 70 |
+
sample_size: Number of samples evaluated
|
| 71 |
+
dataset_hash: Hash of the dataset
|
| 72 |
+
config_hash: Hash of the configuration
|
| 73 |
+
policy_version: Version of the policy
|
| 74 |
+
random_seed: Random seed used
|
| 75 |
+
scoring_weights: Weights used for scoring
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
EvaluationArtifact instance
|
| 79 |
+
"""
|
| 80 |
+
# Calculate Risk Index
|
| 81 |
+
risk_classifier = RiskClassifier()
|
| 82 |
+
risk_assessment = risk_classifier.assess_risk(H, T, B, RSI or 0.0)
|
| 83 |
+
RiskIndex = risk_assessment["risk_index"]
|
| 84 |
+
|
| 85 |
+
# Get certification tier
|
| 86 |
+
gss_calculator = GSSCalculator()
|
| 87 |
+
certification_tier = gss_calculator.get_certification_tier(R, RSI, H, T)
|
| 88 |
+
|
| 89 |
+
# Calculate confidence interval if sample size is sufficient
|
| 90 |
+
confidence_interval = None
|
| 91 |
+
if sample_size >= 500:
|
| 92 |
+
# For now, use a placeholder std_dev
|
| 93 |
+
# In production, this would be calculated from actual sample data
|
| 94 |
+
std_dev = 0.1 # Placeholder
|
| 95 |
+
ci = calculate_confidence_interval(R, std_dev, sample_size)
|
| 96 |
+
confidence_interval = {
|
| 97 |
+
"lower": ci.lower,
|
| 98 |
+
"upper": ci.upper,
|
| 99 |
+
"confidence_level": ci.confidence_level,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
# Generate artifact
|
| 103 |
+
artifact = EvaluationArtifact(
|
| 104 |
+
model_name=model_name,
|
| 105 |
+
model_version=model_version,
|
| 106 |
+
metrics={
|
| 107 |
+
"H": H,
|
| 108 |
+
"T": T,
|
| 109 |
+
"B": B,
|
| 110 |
+
"C": C,
|
| 111 |
+
"R": R,
|
| 112 |
+
},
|
| 113 |
+
RSI=RSI,
|
| 114 |
+
RiskIndex=RiskIndex,
|
| 115 |
+
certification_tier=certification_tier,
|
| 116 |
+
sample_size=sample_size,
|
| 117 |
+
dataset_hash=dataset_hash,
|
| 118 |
+
config_hash=config_hash,
|
| 119 |
+
policy_version=policy_version,
|
| 120 |
+
random_seed=random_seed,
|
| 121 |
+
scoring_weights=scoring_weights or DEFAULT_WEIGHTS,
|
| 122 |
+
confidence_interval=confidence_interval,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return artifact
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def generate_model_gss1_artifact(
|
| 129 |
+
model_result: Any,
|
| 130 |
+
dataset_hash: str = "",
|
| 131 |
+
config_hash: str = "",
|
| 132 |
+
policy_version: str = "",
|
| 133 |
+
random_seed: int = 0,
|
| 134 |
+
) -> EvaluationArtifact:
|
| 135 |
+
"""
|
| 136 |
+
Generate GSS-1 artifact from a model benchmark result.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
model_result: ModelBenchmarkResult from benchmarking
|
| 140 |
+
dataset_hash: Hash of the dataset
|
| 141 |
+
config_hash: Hash of the configuration
|
| 142 |
+
policy_version: Version of the policy
|
| 143 |
+
random_seed: Random seed used
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
EvaluationArtifact instance
|
| 147 |
+
"""
|
| 148 |
+
# Get metrics from adversarial evaluation (or baseline if adversarial not available)
|
| 149 |
+
eval_result = model_result.adversarial or model_result.baseline
|
| 150 |
+
|
| 151 |
+
if not eval_result or not eval_result.metrics:
|
| 152 |
+
raise ValueError(f"No evaluation metrics available for model {model_result.model_name}")
|
| 153 |
+
|
| 154 |
+
H = eval_result.metrics.hallucination
|
| 155 |
+
T = eval_result.metrics.toxicity
|
| 156 |
+
B = eval_result.metrics.bias
|
| 157 |
+
C = eval_result.metrics.confidence
|
| 158 |
+
R = eval_result.metrics.robustness
|
| 159 |
+
RSI = model_result.robustness_stability_index
|
| 160 |
+
sample_size = eval_result.sample_count
|
| 161 |
+
|
| 162 |
+
return generate_gss1_artifact(
|
| 163 |
+
model_name=model_result.model_name,
|
| 164 |
+
model_version="1.0.0", # Default version
|
| 165 |
+
H=H,
|
| 166 |
+
T=T,
|
| 167 |
+
B=B,
|
| 168 |
+
C=C,
|
| 169 |
+
R=R,
|
| 170 |
+
RSI=RSI,
|
| 171 |
+
sample_size=sample_size,
|
| 172 |
+
dataset_hash=dataset_hash,
|
| 173 |
+
config_hash=config_hash,
|
| 174 |
+
policy_version=policy_version,
|
| 175 |
+
random_seed=random_seed,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# =============================================================================
|
| 180 |
+
# Artifact Generation
|
| 181 |
+
# =============================================================================
|
| 182 |
+
|
| 183 |
+
def generate_benchmark_artifact(
|
| 184 |
+
result: BenchmarkResult,
|
| 185 |
+
output_dir: Optional[str] = None,
|
| 186 |
+
generate_gss1: bool = True,
|
| 187 |
+
) -> str:
|
| 188 |
+
"""
|
| 189 |
+
Generate benchmark artifact JSON file.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
result: Complete benchmark result
|
| 193 |
+
output_dir: Optional output directory (defaults to experiments/benchmarks)
|
| 194 |
+
generate_gss1: Whether to also generate GSS-1 artifacts
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Path to the generated artifact file
|
| 198 |
+
"""
|
| 199 |
+
if output_dir is None:
|
| 200 |
+
output_dir = DEFAULT_BENCHMARK_DIR
|
| 201 |
+
|
| 202 |
+
# Create output directory if it doesn't exist
|
| 203 |
+
artifact_dir = Path(output_dir)
|
| 204 |
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
| 205 |
+
|
| 206 |
+
# Generate filename
|
| 207 |
+
filename = f"{result.benchmark_id}.json"
|
| 208 |
+
artifact_path = artifact_dir / filename
|
| 209 |
+
|
| 210 |
+
# Convert result to dictionary
|
| 211 |
+
artifact_data = result_to_dict(result)
|
| 212 |
+
|
| 213 |
+
# Add GSS-1 artifacts if requested
|
| 214 |
+
if generate_gss1:
|
| 215 |
+
gss1_artifacts = []
|
| 216 |
+
for model_result in result.results:
|
| 217 |
+
try:
|
| 218 |
+
artifact = generate_model_gss1_artifact(
|
| 219 |
+
model_result,
|
| 220 |
+
dataset_hash=result.dataset_version or "",
|
| 221 |
+
config_hash=str(result.benchmark_id),
|
| 222 |
+
)
|
| 223 |
+
gss1_artifacts.append(artifact.to_dict())
|
| 224 |
+
except Exception:
|
| 225 |
+
continue
|
| 226 |
+
|
| 227 |
+
if gss1_artifacts:
|
| 228 |
+
artifact_data["gss1_artifacts"] = gss1_artifacts
|
| 229 |
+
|
| 230 |
+
# Write to file
|
| 231 |
+
with open(artifact_path, "w") as f:
|
| 232 |
+
json.dump(artifact_data, f, indent=2, default=str)
|
| 233 |
+
|
| 234 |
+
return str(artifact_path)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def result_to_dict(result: BenchmarkResult) -> Dict[str, Any]:
|
| 238 |
+
"""
|
| 239 |
+
Convert BenchmarkResult to dictionary for JSON serialization.
|
| 240 |
+
|
| 241 |
+
Args:
|
| 242 |
+
result: BenchmarkResult to convert
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
Dictionary representation
|
| 246 |
+
"""
|
| 247 |
+
data = {
|
| 248 |
+
"benchmark_id": str(result.benchmark_id),
|
| 249 |
+
"dataset_name": result.dataset_name,
|
| 250 |
+
"dataset_version": result.dataset_version,
|
| 251 |
+
"models": result.models,
|
| 252 |
+
"status": result.status.value,
|
| 253 |
+
"started_at": result.started_at.isoformat() if result.started_at else None,
|
| 254 |
+
"completed_at": result.completed_at.isoformat() if result.completed_at else None,
|
| 255 |
+
"error": result.error,
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
# Add results per model
|
| 259 |
+
results_list = []
|
| 260 |
+
for model_result in result.results:
|
| 261 |
+
model_data = {
|
| 262 |
+
"model_name": model_result.model_name,
|
| 263 |
+
"baseline_robustness": model_result.baseline_robustness,
|
| 264 |
+
"adversarial_robustness": model_result.adversarial_robustness,
|
| 265 |
+
"delta_robustness": model_result.delta_robustness,
|
| 266 |
+
"robustness_stability_index": model_result.robustness_stability_index,
|
| 267 |
+
"vulnerability_index": model_result.vulnerability_index,
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# Add baseline metrics
|
| 271 |
+
if model_result.baseline:
|
| 272 |
+
model_data["baseline"] = {
|
| 273 |
+
"mode": model_result.baseline.mode.value,
|
| 274 |
+
"sample_count": model_result.baseline.sample_count,
|
| 275 |
+
"failure_rate": model_result.baseline.failure_rate,
|
| 276 |
+
"metrics": model_result.baseline.metrics.model_dump() if model_result.baseline.metrics else None,
|
| 277 |
+
"mean_latency_ms": model_result.baseline.mean_latency_ms,
|
| 278 |
+
"total_time_seconds": model_result.baseline.total_time_seconds,
|
| 279 |
+
"timestamp": model_result.baseline.timestamp.isoformat() if model_result.baseline.timestamp else None,
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
# Add adversarial metrics
|
| 283 |
+
if model_result.adversarial:
|
| 284 |
+
model_data["adversarial"] = {
|
| 285 |
+
"mode": model_result.adversarial.mode.value,
|
| 286 |
+
"sample_count": model_result.adversarial.sample_count,
|
| 287 |
+
"failure_rate": model_result.adversarial.failure_rate,
|
| 288 |
+
"metrics": model_result.adversarial.metrics.model_dump() if model_result.adversarial.metrics else None,
|
| 289 |
+
"mean_latency_ms": model_result.adversarial.mean_latency_ms,
|
| 290 |
+
"total_time_seconds": model_result.adversarial.total_time_seconds,
|
| 291 |
+
"timestamp": model_result.adversarial.timestamp.isoformat() if model_result.adversarial.timestamp else None,
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
# Add deltas
|
| 295 |
+
if model_result.deltas:
|
| 296 |
+
model_data["deltas"] = model_result.deltas.model_dump()
|
| 297 |
+
|
| 298 |
+
results_list.append(model_data)
|
| 299 |
+
|
| 300 |
+
data["results"] = results_list
|
| 301 |
+
|
| 302 |
+
# Add rankings
|
| 303 |
+
if result.rankings:
|
| 304 |
+
data["rankings"] = [
|
| 305 |
+
ranking.model_dump() for ranking in result.rankings
|
| 306 |
+
]
|
| 307 |
+
|
| 308 |
+
# Add vulnerability heatmap
|
| 309 |
+
if result.vulnerability_heatmap:
|
| 310 |
+
data["vulnerability_heatmap"] = {
|
| 311 |
+
"rows": result.vulnerability_heatmap.rows,
|
| 312 |
+
"columns": result.vulnerability_heatmap.columns,
|
| 313 |
+
"cells": [
|
| 314 |
+
cell.model_dump() for cell in result.vulnerability_heatmap.cells
|
| 315 |
+
],
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
# Add performance tracking
|
| 319 |
+
data["performance"] = {
|
| 320 |
+
"time_per_model_seconds": result.performance.time_per_model_seconds,
|
| 321 |
+
"gpu_memory_mb": result.performance.gpu_memory_mb,
|
| 322 |
+
"sample_counts": result.performance.sample_counts,
|
| 323 |
+
"failure_rates": result.performance.failure_rates,
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
# Add config
|
| 327 |
+
if result.config:
|
| 328 |
+
data["config"] = result.config
|
| 329 |
+
|
| 330 |
+
return data
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# =============================================================================
|
| 334 |
+
# Report Generation
|
| 335 |
+
# =============================================================================
|
| 336 |
+
|
| 337 |
+
def generate_text_report(result: BenchmarkResult) -> str:
|
| 338 |
+
"""
|
| 339 |
+
Generate human-readable text report.
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
result: BenchmarkResult to report
|
| 343 |
+
|
| 344 |
+
Returns:
|
| 345 |
+
Formatted text report
|
| 346 |
+
"""
|
| 347 |
+
lines = []
|
| 348 |
+
|
| 349 |
+
# Header
|
| 350 |
+
lines.append("=" * 60)
|
| 351 |
+
lines.append(f"AEGISLM BENCHMARK REPORT")
|
| 352 |
+
lines.append("=" * 60)
|
| 353 |
+
lines.append(f"Benchmark ID: {result.benchmark_id}")
|
| 354 |
+
lines.append(f"Status: {result.status.value}")
|
| 355 |
+
lines.append(f"Dataset: {result.dataset_name} ({result.dataset_version})")
|
| 356 |
+
lines.append(f"Models Evaluated: {', '.join(result.models)}")
|
| 357 |
+
lines.append("")
|
| 358 |
+
|
| 359 |
+
# Timing
|
| 360 |
+
lines.append(f"Started: {result.started_at.isoformat() if result.started_at else 'N/A'}")
|
| 361 |
+
if result.completed_at:
|
| 362 |
+
duration = (result.completed_at - result.started_at).total_seconds() if result.started_at else 0
|
| 363 |
+
lines.append(f"Completed: {result.completed_at.isoformat()}")
|
| 364 |
+
lines.append(f"Duration: {duration:.2f} seconds")
|
| 365 |
+
lines.append("")
|
| 366 |
+
|
| 367 |
+
# Results per model
|
| 368 |
+
lines.append("-" * 60)
|
| 369 |
+
lines.append("MODEL RESULTS")
|
| 370 |
+
lines.append("-" * 60)
|
| 371 |
+
|
| 372 |
+
for model_result in result.results:
|
| 373 |
+
lines.append(f"\nModel: {model_result.model_name}")
|
| 374 |
+
|
| 375 |
+
if model_result.baseline:
|
| 376 |
+
lines.append(f" Baseline Robustness: {model_result.baseline_robustness:.4f}" if model_result.baseline_robustness else " Baseline: N/A")
|
| 377 |
+
|
| 378 |
+
if model_result.adversarial:
|
| 379 |
+
lines.append(f" Adversarial Robustness: {model_result.adversarial_robustness:.4f}" if model_result.adversarial_robustness else " Adversarial: N/A")
|
| 380 |
+
|
| 381 |
+
if model_result.delta_robustness is not None:
|
| 382 |
+
lines.append(f" Delta Robustness: {model_result.delta_robustness:.4f}")
|
| 383 |
+
|
| 384 |
+
if model_result.robustness_stability_index is not None:
|
| 385 |
+
lines.append(f" Robustness Stability Index (RSI): {model_result.robustness_stability_index:.4f}")
|
| 386 |
+
|
| 387 |
+
if model_result.vulnerability_index is not None:
|
| 388 |
+
lines.append(f" Vulnerability Index: {model_result.vulnerability_index:.4f}")
|
| 389 |
+
|
| 390 |
+
# Add GSS-1 certification info if available
|
| 391 |
+
try:
|
| 392 |
+
artifact = generate_model_gss1_artifact(model_result)
|
| 393 |
+
lines.append(f" GSS-1 Certification Tier: {artifact.certification_tier}")
|
| 394 |
+
if artifact.RiskIndex is not None:
|
| 395 |
+
lines.append(f" Risk Index: {artifact.RiskIndex:.4f}")
|
| 396 |
+
except Exception:
|
| 397 |
+
pass
|
| 398 |
+
|
| 399 |
+
# Rankings
|
| 400 |
+
if result.rankings:
|
| 401 |
+
lines.append("")
|
| 402 |
+
lines.append("-" * 60)
|
| 403 |
+
lines.append("MODEL RANKINGS")
|
| 404 |
+
lines.append("-" * 60)
|
| 405 |
+
|
| 406 |
+
for ranking in result.rankings:
|
| 407 |
+
lines.append(f" {ranking.rank}. {ranking.model_name} (Score: {ranking.overall_score:.4f})")
|
| 408 |
+
|
| 409 |
+
# Performance
|
| 410 |
+
lines.append("")
|
| 411 |
+
lines.append("-" * 60)
|
| 412 |
+
lines.append("PERFORMANCE")
|
| 413 |
+
lines.append("-" * 60)
|
| 414 |
+
|
| 415 |
+
for model_name, time_seconds in result.performance.time_per_model_seconds.items():
|
| 416 |
+
lines.append(f" {model_name}: {time_seconds:.2f} seconds")
|
| 417 |
+
|
| 418 |
+
# Error
|
| 419 |
+
if result.error:
|
| 420 |
+
lines.append("")
|
| 421 |
+
lines.append("-" * 60)
|
| 422 |
+
lines.append("ERROR")
|
| 423 |
+
lines.append("-" * 60)
|
| 424 |
+
lines.append(f" {result.error}")
|
| 425 |
+
|
| 426 |
+
lines.append("")
|
| 427 |
+
lines.append("=" * 60)
|
| 428 |
+
|
| 429 |
+
return "\n".join(lines)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def generate_summary_report(result: BenchmarkResult) -> Dict[str, Any]:
|
| 433 |
+
"""
|
| 434 |
+
Generate summary report as dictionary.
|
| 435 |
+
|
| 436 |
+
Args:
|
| 437 |
+
result: BenchmarkResult to summarize
|
| 438 |
+
|
| 439 |
+
Returns:
|
| 440 |
+
Summary dictionary
|
| 441 |
+
"""
|
| 442 |
+
summary = {
|
| 443 |
+
"benchmark_id": str(result.benchmark_id),
|
| 444 |
+
"status": result.status.value,
|
| 445 |
+
"dataset": f"{result.dataset_name} ({result.dataset_version})",
|
| 446 |
+
"total_models": len(result.models),
|
| 447 |
+
"successful_evaluations": len([r for r in result.results if r.adversarial is not None]),
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
# Find best and worst
|
| 451 |
+
valid_results = [r for r in result.results if r.adversarial_robustness is not None]
|
| 452 |
+
|
| 453 |
+
if valid_results:
|
| 454 |
+
best = max(valid_results, key=lambda x: x.adversarial_robustness)
|
| 455 |
+
worst = min(valid_results, key=lambda x: x.adversarial_robustness)
|
| 456 |
+
|
| 457 |
+
summary["best_model"] = {
|
| 458 |
+
"name": best.model_name,
|
| 459 |
+
"robustness": best.adversarial_robustness,
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
summary["worst_model"] = {
|
| 463 |
+
"name": worst.model_name,
|
| 464 |
+
"robustness": worst.adversarial_robustness,
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
# Average robustness
|
| 468 |
+
avg_robustness = sum(r.adversarial_robustness for r in valid_results) / len(valid_results)
|
| 469 |
+
summary["average_robustness"] = avg_robustness
|
| 470 |
+
|
| 471 |
+
# Rankings summary
|
| 472 |
+
if result.rankings and result.rankings:
|
| 473 |
+
summary["top_model"] = result.rankings[0].model_name
|
| 474 |
+
summary["top_score"] = result.rankings[0].overall_score
|
| 475 |
+
|
| 476 |
+
# Add GSS-1 certification summary
|
| 477 |
+
try:
|
| 478 |
+
cert_tiers = {}
|
| 479 |
+
for model_result in valid_results:
|
| 480 |
+
artifact = generate_model_gss1_artifact(model_result)
|
| 481 |
+
tier = artifact.certification_tier
|
| 482 |
+
cert_tiers[model_result.model_name] = tier
|
| 483 |
+
summary["certification_tiers"] = cert_tiers
|
| 484 |
+
except Exception:
|
| 485 |
+
pass
|
| 486 |
+
|
| 487 |
+
return summary
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
# =============================================================================
|
| 491 |
+
# Artifact Loading
|
| 492 |
+
# =============================================================================
|
| 493 |
+
|
| 494 |
+
def load_benchmark_artifact(
|
| 495 |
+
benchmark_id: str,
|
| 496 |
+
input_dir: Optional[str] = None,
|
| 497 |
+
) -> Optional[Dict[str, Any]]:
|
| 498 |
+
"""
|
| 499 |
+
Load benchmark artifact from file.
|
| 500 |
+
|
| 501 |
+
Args:
|
| 502 |
+
benchmark_id: The benchmark ID to load
|
| 503 |
+
input_dir: Optional input directory
|
| 504 |
+
|
| 505 |
+
Returns:
|
| 506 |
+
Dictionary representation of the benchmark, or None if not found
|
| 507 |
+
"""
|
| 508 |
+
if input_dir is None:
|
| 509 |
+
input_dir = DEFAULT_BENCHMARK_DIR
|
| 510 |
+
|
| 511 |
+
artifact_path = Path(input_dir) / f"{benchmark_id}.json"
|
| 512 |
+
|
| 513 |
+
if not artifact_path.exists():
|
| 514 |
+
return None
|
| 515 |
+
|
| 516 |
+
with open(artifact_path, "r") as f:
|
| 517 |
+
return json.load(f)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def list_benchmarks(
|
| 521 |
+
input_dir: Optional[str] = None,
|
| 522 |
+
) -> List[Dict[str, Any]]:
|
| 523 |
+
"""
|
| 524 |
+
List all available benchmarks.
|
| 525 |
+
|
| 526 |
+
Args:
|
| 527 |
+
input_dir: Optional input directory
|
| 528 |
+
|
| 529 |
+
Returns:
|
| 530 |
+
List of benchmark summaries
|
| 531 |
+
"""
|
| 532 |
+
if input_dir is None:
|
| 533 |
+
input_dir = DEFAULT_BENCHMARK_DIR
|
| 534 |
+
|
| 535 |
+
benchmark_dir = Path(input_dir)
|
| 536 |
+
|
| 537 |
+
if not benchmark_dir.exists():
|
| 538 |
+
return []
|
| 539 |
+
|
| 540 |
+
benchmarks = []
|
| 541 |
+
|
| 542 |
+
for artifact_file in benchmark_dir.glob("*.json"):
|
| 543 |
+
try:
|
| 544 |
+
with open(artifact_file, "r") as f:
|
| 545 |
+
data = json.load(f)
|
| 546 |
+
benchmarks.append({
|
| 547 |
+
"benchmark_id": data.get("benchmark_id"),
|
| 548 |
+
"status": data.get("status"),
|
| 549 |
+
"dataset": f"{data.get('dataset_name')} ({data.get('dataset_version')})",
|
| 550 |
+
"models": data.get("models", []),
|
| 551 |
+
"started_at": data.get("started_at"),
|
| 552 |
+
"completed_at": data.get("completed_at"),
|
| 553 |
+
})
|
| 554 |
+
except Exception:
|
| 555 |
+
continue
|
| 556 |
+
|
| 557 |
+
# Sort by started_at (newest first)
|
| 558 |
+
benchmarks.sort(key=lambda x: x.get("started_at", ""), reverse=True)
|
| 559 |
+
|
| 560 |
+
return benchmarks
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# =============================================================================
|
| 564 |
+
# Export Functions
|
| 565 |
+
# =============================================================================
|
| 566 |
+
|
| 567 |
+
def export_to_csv(
|
| 568 |
+
result: BenchmarkResult,
|
| 569 |
+
output_path: str,
|
| 570 |
+
) -> None:
|
| 571 |
+
"""
|
| 572 |
+
Export benchmark results to CSV.
|
| 573 |
+
|
| 574 |
+
Args:
|
| 575 |
+
result: BenchmarkResult to export
|
| 576 |
+
output_path: Path to output CSV file
|
| 577 |
+
"""
|
| 578 |
+
import csv
|
| 579 |
+
|
| 580 |
+
with open(output_path, "w", newline="") as f:
|
| 581 |
+
writer = csv.writer(f)
|
| 582 |
+
|
| 583 |
+
# Header
|
| 584 |
+
writer.writerow([
|
| 585 |
+
"Model",
|
| 586 |
+
"Baseline Robustness",
|
| 587 |
+
"Adversarial Robustness",
|
| 588 |
+
"Delta Robustness",
|
| 589 |
+
"RSI",
|
| 590 |
+
"Vulnerability Index",
|
| 591 |
+
"Hallucination Delta",
|
| 592 |
+
"Toxicity Delta",
|
| 593 |
+
"Bias Delta",
|
| 594 |
+
"Confidence Delta",
|
| 595 |
+
])
|
| 596 |
+
|
| 597 |
+
# Data rows
|
| 598 |
+
for model_result in result.results:
|
| 599 |
+
writer.writerow([
|
| 600 |
+
model_result.model_name,
|
| 601 |
+
model_result.baseline_robustness or "",
|
| 602 |
+
model_result.adversarial_robustness or "",
|
| 603 |
+
model_result.delta_robustness or "",
|
| 604 |
+
model_result.robustness_stability_index or "",
|
| 605 |
+
model_result.vulnerability_index or "",
|
| 606 |
+
model_result.deltas.hallucination_delta if model_result.deltas else "",
|
| 607 |
+
model_result.deltas.toxicity_delta if model_result.deltas else "",
|
| 608 |
+
model_result.deltas.bias_delta if model_result.deltas else "",
|
| 609 |
+
model_result.deltas.confidence_delta if model_result.deltas else "",
|
| 610 |
+
])
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
__all__ = [
|
| 614 |
+
"DEFAULT_BENCHMARK_DIR",
|
| 615 |
+
"generate_benchmark_artifact",
|
| 616 |
+
"generate_gss1_artifact",
|
| 617 |
+
"generate_model_gss1_artifact",
|
| 618 |
+
"generate_text_report",
|
| 619 |
+
"generate_summary_report",
|
| 620 |
+
"load_benchmark_artifact",
|
| 621 |
+
"list_benchmarks",
|
| 622 |
+
"export_to_csv",
|
| 623 |
+
]
|
backend/benchmarking/schemas.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmarking Schemas
|
| 3 |
+
|
| 4 |
+
Data models for the AegisLM Benchmarking Engine.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
from uuid import UUID
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field, field_validator
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# =============================================================================
|
| 16 |
+
# Enums
|
| 17 |
+
# =============================================================================
|
| 18 |
+
|
| 19 |
+
class BenchmarkMode(str, Enum):
|
| 20 |
+
"""Benchmark evaluation mode."""
|
| 21 |
+
BASELINE = "baseline"
|
| 22 |
+
ADVERSARIAL = "adversarial"
|
| 23 |
+
CROSS_MODEL = "cross_model"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BenchmarkStatus(str, Enum):
|
| 27 |
+
"""Status of a benchmark run."""
|
| 28 |
+
PENDING = "pending"
|
| 29 |
+
RUNNING = "running"
|
| 30 |
+
COMPLETED = "completed"
|
| 31 |
+
FAILED = "failed"
|
| 32 |
+
CANCELLED = "cancelled"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# =============================================================================
|
| 36 |
+
# Configuration Models
|
| 37 |
+
# =============================================================================
|
| 38 |
+
|
| 39 |
+
class BenchmarkWeights(BaseModel):
|
| 40 |
+
"""Weights for benchmark scoring."""
|
| 41 |
+
hallucination: float = Field(default=0.25, ge=0.0, le=1.0)
|
| 42 |
+
toxicity: float = Field(default=0.25, ge=0.0, le=1.0)
|
| 43 |
+
bias: float = Field(default=0.25, ge=0.0, le=1.0)
|
| 44 |
+
confidence: float = Field(default=0.25, ge=0.0, le=1.0)
|
| 45 |
+
|
| 46 |
+
@field_validator("hallucination", "toxicity", "bias", "confidence")
|
| 47 |
+
@classmethod
|
| 48 |
+
def validate_weight(cls, v: float) -> float:
|
| 49 |
+
if not 0.0 <= v <= 1.0:
|
| 50 |
+
raise ValueError(f"Weight must be in [0, 1], got {v}")
|
| 51 |
+
return v
|
| 52 |
+
|
| 53 |
+
def validate_sum(self) -> None:
|
| 54 |
+
"""Validate that weights sum to 1.0."""
|
| 55 |
+
total = self.hallucination + self.toxicity + self.bias + self.confidence
|
| 56 |
+
if abs(total - 1.0) > 1e-6:
|
| 57 |
+
raise ValueError(f"Weights must sum to 1.0, got {total}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class BenchmarkConfig(BaseModel):
|
| 61 |
+
"""
|
| 62 |
+
Configuration for a benchmark run.
|
| 63 |
+
|
| 64 |
+
This is the main entry point for starting a benchmark evaluation.
|
| 65 |
+
"""
|
| 66 |
+
benchmark_id: UUID = Field(description="Unique benchmark identifier")
|
| 67 |
+
models: List[str] = Field(description="List of model names to evaluate")
|
| 68 |
+
dataset_name: str = Field(description="Dataset name")
|
| 69 |
+
dataset_version: str = Field(description="Dataset version to use")
|
| 70 |
+
attack_enabled: bool = Field(default=True, description="Enable attack generation")
|
| 71 |
+
mutation_depth: int = Field(default=2, ge=0, le=10, description="Mutation depth")
|
| 72 |
+
weights: BenchmarkWeights = Field(
|
| 73 |
+
default_factory=BenchmarkWeights,
|
| 74 |
+
description="Scoring weights"
|
| 75 |
+
)
|
| 76 |
+
max_concurrency: int = Field(default=4, ge=1, le=32)
|
| 77 |
+
max_samples: Optional[int] = Field(
|
| 78 |
+
default=None,
|
| 79 |
+
description="Maximum samples per model (for quick benchmarking)"
|
| 80 |
+
)
|
| 81 |
+
enable_baseline: bool = Field(
|
| 82 |
+
default=True,
|
| 83 |
+
description="Run baseline evaluation (no attacks)"
|
| 84 |
+
)
|
| 85 |
+
enable_adversarial: bool = Field(
|
| 86 |
+
default=True,
|
| 87 |
+
description="Run adversarial evaluation (with attacks)"
|
| 88 |
+
)
|
| 89 |
+
attack_types: List[str] = Field(
|
| 90 |
+
default_factory=lambda: ["jailbreak"],
|
| 91 |
+
description="Attack types to use"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def validate_config(self) -> None:
|
| 95 |
+
"""Validate benchmark configuration."""
|
| 96 |
+
self.weights.validate_sum()
|
| 97 |
+
if not self.models:
|
| 98 |
+
raise ValueError("At least one model must be specified")
|
| 99 |
+
if not self.enable_baseline and not self.enable_adversarial:
|
| 100 |
+
raise ValueError("At least one of baseline or adversarial must be enabled")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# =============================================================================
|
| 104 |
+
# Result Models
|
| 105 |
+
# =============================================================================
|
| 106 |
+
|
| 107 |
+
class ModelMetrics(BaseModel):
|
| 108 |
+
"""Metrics for a single model evaluation."""
|
| 109 |
+
hallucination: float = Field(ge=0.0, le=1.0)
|
| 110 |
+
toxicity: float = Field(ge=0.0, le=1.0)
|
| 111 |
+
bias: float = Field(ge=0.0, le=1.0)
|
| 112 |
+
confidence: float = Field(ge=0.0, le=1.0)
|
| 113 |
+
robustness: float = Field(ge=0.0, le=1.0)
|
| 114 |
+
|
| 115 |
+
# Standard deviations
|
| 116 |
+
std_hallucination: Optional[float] = None
|
| 117 |
+
std_toxicity: Optional[float] = None
|
| 118 |
+
std_bias: Optional[float] = None
|
| 119 |
+
std_confidence: Optional[float] = None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class MetricDeltas(BaseModel):
|
| 123 |
+
"""
|
| 124 |
+
Delta (change) in metrics between baseline and adversarial.
|
| 125 |
+
|
| 126 |
+
Positive delta means metric worsened under adversarial conditions.
|
| 127 |
+
"""
|
| 128 |
+
hallucination_delta: float = Field(
|
| 129 |
+
description="Mean hallucination change (adversarial - baseline)"
|
| 130 |
+
)
|
| 131 |
+
toxicity_delta: float = Field(
|
| 132 |
+
description="Mean toxicity change (adversarial - baseline)"
|
| 133 |
+
)
|
| 134 |
+
bias_delta: float = Field(
|
| 135 |
+
description="Mean bias change (adversarial - baseline)"
|
| 136 |
+
)
|
| 137 |
+
confidence_delta: float = Field(
|
| 138 |
+
description="Mean confidence change (adversarial - baseline)"
|
| 139 |
+
)
|
| 140 |
+
robustness_delta: float = Field(
|
| 141 |
+
description="Robustness change (baseline - adversarial)"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class EvaluationResult(BaseModel):
|
| 146 |
+
"""Result of a single model evaluation."""
|
| 147 |
+
model_name: str
|
| 148 |
+
mode: BenchmarkMode
|
| 149 |
+
metrics: ModelMetrics
|
| 150 |
+
sample_count: int
|
| 151 |
+
failure_rate: float = Field(ge=0.0, le=1.0)
|
| 152 |
+
mean_latency_ms: Optional[float] = None
|
| 153 |
+
total_time_seconds: Optional[float] = None
|
| 154 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class ModelBenchmarkResult(BaseModel):
|
| 158 |
+
"""
|
| 159 |
+
Complete benchmark result for a single model.
|
| 160 |
+
|
| 161 |
+
Contains both baseline and adversarial results, plus computed deltas.
|
| 162 |
+
"""
|
| 163 |
+
model_name: str
|
| 164 |
+
baseline: Optional[EvaluationResult] = None
|
| 165 |
+
adversarial: Optional[EvaluationResult] = None
|
| 166 |
+
deltas: Optional[MetricDeltas] = None
|
| 167 |
+
|
| 168 |
+
# Derived metrics
|
| 169 |
+
baseline_robustness: Optional[float] = None
|
| 170 |
+
adversarial_robustness: Optional[float] = None
|
| 171 |
+
delta_robustness: Optional[float] = None
|
| 172 |
+
|
| 173 |
+
# Additional metrics
|
| 174 |
+
robustness_stability_index: Optional[float] = Field(
|
| 175 |
+
default=None,
|
| 176 |
+
description="RSI = R_adv / R_base (closer to 1 = more stable)"
|
| 177 |
+
)
|
| 178 |
+
vulnerability_index: Optional[float] = Field(
|
| 179 |
+
default=None,
|
| 180 |
+
description="VI = delta_R / R_base (higher = more fragile)"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# =============================================================================
|
| 185 |
+
# Ranking and Comparison
|
| 186 |
+
# =============================================================================
|
| 187 |
+
|
| 188 |
+
class ModelRanking(BaseModel):
|
| 189 |
+
"""Ranking information for a model."""
|
| 190 |
+
model_name: str
|
| 191 |
+
rank: int
|
| 192 |
+
robustness_score: float
|
| 193 |
+
hallucination_resilience: float
|
| 194 |
+
bias_stability: float
|
| 195 |
+
confidence_retention: float
|
| 196 |
+
overall_score: float
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class VulnerabilityHeatmapCell(BaseModel):
|
| 200 |
+
"""Single cell in the vulnerability heatmap."""
|
| 201 |
+
attack_type: str
|
| 202 |
+
metric: str
|
| 203 |
+
value: float = Field(ge=0.0, le=1.0)
|
| 204 |
+
sample_count: int
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class VulnerabilityHeatmap(BaseModel):
|
| 208 |
+
"""Vulnerability heatmap matrix."""
|
| 209 |
+
rows: List[str] = Field(description="Attack types")
|
| 210 |
+
columns: List[str] = Field(description="Metrics")
|
| 211 |
+
cells: List[VulnerabilityHeatmapCell]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# =============================================================================
|
| 215 |
+
# Benchmark Output
|
| 216 |
+
# =============================================================================
|
| 217 |
+
|
| 218 |
+
class BenchmarkPerformance(BaseModel):
|
| 219 |
+
"""Performance tracking for a benchmark."""
|
| 220 |
+
time_per_model_seconds: Dict[str, float] = Field(default_factory=dict)
|
| 221 |
+
gpu_memory_mb: Optional[Dict[str, float]] = None
|
| 222 |
+
sample_counts: Dict[str, int] = Field(default_factory=dict)
|
| 223 |
+
failure_rates: Dict[str, float] = Field(default_factory=dict)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class BenchmarkResult(BaseModel):
|
| 227 |
+
"""
|
| 228 |
+
Complete benchmark result for multiple models.
|
| 229 |
+
"""
|
| 230 |
+
benchmark_id: UUID
|
| 231 |
+
dataset_name: str
|
| 232 |
+
dataset_version: str
|
| 233 |
+
models: List[str]
|
| 234 |
+
status: BenchmarkStatus
|
| 235 |
+
|
| 236 |
+
# Results per model
|
| 237 |
+
results: List[ModelBenchmarkResult]
|
| 238 |
+
|
| 239 |
+
# Rankings (if multiple models)
|
| 240 |
+
rankings: Optional[List[ModelRanking]] = None
|
| 241 |
+
|
| 242 |
+
# Vulnerability heatmap
|
| 243 |
+
vulnerability_heatmap: Optional[VulnerabilityHeatmap] = None
|
| 244 |
+
|
| 245 |
+
# Performance tracking
|
| 246 |
+
performance: BenchmarkPerformance
|
| 247 |
+
|
| 248 |
+
# Timestamps
|
| 249 |
+
started_at: datetime
|
| 250 |
+
completed_at: Optional[datetime] = None
|
| 251 |
+
|
| 252 |
+
# Error information
|
| 253 |
+
error: Optional[str] = None
|
| 254 |
+
|
| 255 |
+
# Configuration used
|
| 256 |
+
config: Optional[Dict[str, Any]] = None
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# =============================================================================
|
| 260 |
+
# API Input/Output
|
| 261 |
+
# =============================================================================
|
| 262 |
+
|
| 263 |
+
class StartBenchmarkRequest(BaseModel):
|
| 264 |
+
"""Request to start a benchmark."""
|
| 265 |
+
models: List[str] = Field(description="List of model names to evaluate")
|
| 266 |
+
dataset_name: str = Field(default="truthfulqa")
|
| 267 |
+
dataset_version: str = Field(default="v1.0")
|
| 268 |
+
attack_enabled: bool = Field(default=True)
|
| 269 |
+
mutation_depth: int = Field(default=2)
|
| 270 |
+
weights: Optional[BenchmarkWeights] = None
|
| 271 |
+
max_concurrency: int = Field(default=4)
|
| 272 |
+
max_samples: Optional[int] = None
|
| 273 |
+
enable_baseline: bool = Field(default=True)
|
| 274 |
+
enable_adversarial: bool = Field(default=True)
|
| 275 |
+
attack_types: Optional[List[str]] = None
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class StartBenchmarkResponse(BaseModel):
|
| 279 |
+
"""Response from starting a benchmark."""
|
| 280 |
+
benchmark_id: UUID
|
| 281 |
+
status: BenchmarkStatus
|
| 282 |
+
message: str
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
class BenchmarkStatusResponse(BaseModel):
|
| 286 |
+
"""Status response for a benchmark."""
|
| 287 |
+
benchmark_id: UUID
|
| 288 |
+
status: BenchmarkStatus
|
| 289 |
+
progress: Optional[float] = None
|
| 290 |
+
completed_models: Optional[List[str]] = None
|
| 291 |
+
current_model: Optional[str] = None
|
| 292 |
+
error: Optional[str] = None
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
__all__ = [
|
| 296 |
+
"BenchmarkMode",
|
| 297 |
+
"BenchmarkStatus",
|
| 298 |
+
"BenchmarkWeights",
|
| 299 |
+
"BenchmarkConfig",
|
| 300 |
+
"ModelMetrics",
|
| 301 |
+
"MetricDeltas",
|
| 302 |
+
"EvaluationResult",
|
| 303 |
+
"ModelBenchmarkResult",
|
| 304 |
+
"ModelRanking",
|
| 305 |
+
"VulnerabilityHeatmapCell",
|
| 306 |
+
"VulnerabilityHeatmap",
|
| 307 |
+
"BenchmarkPerformance",
|
| 308 |
+
"BenchmarkResult",
|
| 309 |
+
"StartBenchmarkRequest",
|
| 310 |
+
"StartBenchmarkResponse",
|
| 311 |
+
"BenchmarkStatusResponse",
|
| 312 |
+
]
|
backend/benchmarking/statistics.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Statistical Validation Module
|
| 3 |
+
|
| 4 |
+
Provides statistical functions for benchmark analysis:
|
| 5 |
+
- Standard deviation calculation
|
| 6 |
+
- Paired difference testing
|
| 7 |
+
- Confidence intervals
|
| 8 |
+
- Statistical significance testing
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
from typing import Dict, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# =============================================================================
|
| 18 |
+
# Basic Statistics
|
| 19 |
+
# =============================================================================
|
| 20 |
+
|
| 21 |
+
def calculate_mean(values: List[float]) -> float:
|
| 22 |
+
"""Calculate arithmetic mean."""
|
| 23 |
+
if not values:
|
| 24 |
+
return 0.0
|
| 25 |
+
return sum(values) / len(values)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def calculate_standard_deviation(values: List[float]) -> float:
|
| 29 |
+
"""
|
| 30 |
+
Calculate standard deviation (population).
|
| 31 |
+
|
| 32 |
+
Formula: σ = sqrt(Σ(xi - μ)² / n)
|
| 33 |
+
"""
|
| 34 |
+
if not values:
|
| 35 |
+
return 0.0
|
| 36 |
+
|
| 37 |
+
n = len(values)
|
| 38 |
+
mean = calculate_mean(values)
|
| 39 |
+
|
| 40 |
+
variance = sum((x - mean) ** 2 for x in values) / n
|
| 41 |
+
return math.sqrt(variance)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def calculate_sample_std(values: List[float]) -> float:
|
| 45 |
+
"""
|
| 46 |
+
Calculate sample standard deviation (unbiased estimator).
|
| 47 |
+
|
| 48 |
+
Formula: s = sqrt(Σ(xi - x̄)² / (n-1))
|
| 49 |
+
"""
|
| 50 |
+
if len(values) < 2:
|
| 51 |
+
return 0.0
|
| 52 |
+
|
| 53 |
+
n = len(values)
|
| 54 |
+
mean = calculate_mean(values)
|
| 55 |
+
|
| 56 |
+
variance = sum((x - mean) ** 2 for x in values) / (n - 1)
|
| 57 |
+
return math.sqrt(variance)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def calculate_variance(values: List[float]) -> float:
|
| 61 |
+
"""Calculate population variance."""
|
| 62 |
+
if not values:
|
| 63 |
+
return 0.0
|
| 64 |
+
|
| 65 |
+
mean = calculate_mean(values)
|
| 66 |
+
return sum((x - mean) ** 2 for x in values) / len(values)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# =============================================================================
|
| 70 |
+
# Standard Deviation for Metrics
|
| 71 |
+
# =============================================================================
|
| 72 |
+
|
| 73 |
+
class MetricStatistics:
|
| 74 |
+
"""Statistics calculator for evaluation metrics."""
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def calculate_all(
|
| 78 |
+
hallucinations: List[float],
|
| 79 |
+
toxicities: List[float],
|
| 80 |
+
biases: List[float],
|
| 81 |
+
confidences: List[float],
|
| 82 |
+
) -> Dict[str, float]:
|
| 83 |
+
"""
|
| 84 |
+
Calculate standard deviations for all metrics.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
Dictionary with std for each metric
|
| 88 |
+
"""
|
| 89 |
+
return {
|
| 90 |
+
"std_hallucination": calculate_standard_deviation(hallucinations),
|
| 91 |
+
"std_toxicity": calculate_standard_deviation(toxicities),
|
| 92 |
+
"std_bias": calculate_standard_deviation(biases),
|
| 93 |
+
"std_confidence": calculate_standard_deviation(confidences),
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
@staticmethod
|
| 97 |
+
def calculate_sample_stds(
|
| 98 |
+
hallucinations: List[float],
|
| 99 |
+
toxicities: List[float],
|
| 100 |
+
biases: List[float],
|
| 101 |
+
confidences: List[float],
|
| 102 |
+
) -> Dict[str, float]:
|
| 103 |
+
"""
|
| 104 |
+
Calculate sample standard deviations for all metrics.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Dictionary with sample std for each metric
|
| 108 |
+
"""
|
| 109 |
+
return {
|
| 110 |
+
"sample_std_hallucination": calculate_sample_std(hallucinations),
|
| 111 |
+
"sample_std_toxicity": calculate_sample_std(toxicities),
|
| 112 |
+
"sample_std_bias": calculate_sample_std(biases),
|
| 113 |
+
"sample_std_confidence": calculate_sample_std(confidences),
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# =============================================================================
|
| 118 |
+
# Confidence Intervals
|
| 119 |
+
# =============================================================================
|
| 120 |
+
|
| 121 |
+
def calculate_confidence_interval(
|
| 122 |
+
values: List[float],
|
| 123 |
+
confidence: float = 0.95,
|
| 124 |
+
) -> Tuple[float, float, float]:
|
| 125 |
+
"""
|
| 126 |
+
Calculate confidence interval for the mean.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
values: List of values
|
| 130 |
+
confidence: Confidence level (default 0.95 for 95%)
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Tuple of (lower_bound, upper_bound, margin_of_error)
|
| 134 |
+
"""
|
| 135 |
+
if len(values) < 2:
|
| 136 |
+
mean = calculate_mean(values)
|
| 137 |
+
return mean, mean, 0.0
|
| 138 |
+
|
| 139 |
+
n = len(values)
|
| 140 |
+
mean = calculate_mean(values)
|
| 141 |
+
std_error = calculate_sample_std(values) / math.sqrt(n)
|
| 142 |
+
|
| 143 |
+
# Z-scores for common confidence levels
|
| 144 |
+
z_scores = {
|
| 145 |
+
0.90: 1.645,
|
| 146 |
+
0.95: 1.96,
|
| 147 |
+
0.99: 2.576,
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
z = z_scores.get(confidence, 1.96)
|
| 151 |
+
margin_of_error = z * std_error
|
| 152 |
+
|
| 153 |
+
return mean - margin_of_error, mean + margin_of_error, margin_of_error
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def calculate_mean_with_ci(
|
| 157 |
+
values: List[float],
|
| 158 |
+
confidence: float = 0.95,
|
| 159 |
+
) -> Dict[str, float]:
|
| 160 |
+
"""
|
| 161 |
+
Calculate mean with confidence interval.
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Dictionary with mean, lower_ci, upper_ci, margin_of_error
|
| 165 |
+
"""
|
| 166 |
+
if not values:
|
| 167 |
+
return {
|
| 168 |
+
"mean": 0.0,
|
| 169 |
+
"lower_ci": 0.0,
|
| 170 |
+
"upper_ci": 0.0,
|
| 171 |
+
"margin_of_error": 0.0,
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
lower, upper, margin = calculate_confidence_interval(values, confidence)
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
"mean": calculate_mean(values),
|
| 178 |
+
"lower_ci": lower,
|
| 179 |
+
"upper_ci": upper,
|
| 180 |
+
"margin_of_error": margin,
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# =============================================================================
|
| 185 |
+
# Paired Difference Test
|
| 186 |
+
# =============================================================================
|
| 187 |
+
|
| 188 |
+
def calculate_paired_differences(
|
| 189 |
+
baseline_values: List[float],
|
| 190 |
+
adversarial_values: List[float],
|
| 191 |
+
) -> List[float]:
|
| 192 |
+
"""
|
| 193 |
+
Calculate paired differences between baseline and adversarial.
|
| 194 |
+
|
| 195 |
+
Di = R_base,i - R_adv,i
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
baseline_values: List of baseline values
|
| 199 |
+
adversarial_values: List of adversarial values
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
List of paired differences
|
| 203 |
+
"""
|
| 204 |
+
if len(baseline_values) != len(adversarial_values):
|
| 205 |
+
raise ValueError(
|
| 206 |
+
"Baseline and adversarial must have same number of values"
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
return [b - a for b, a in zip(baseline_values, adversarial_values)]
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def paired_t_test(
|
| 213 |
+
baseline_values: List[float],
|
| 214 |
+
adversarial_values: List[float],
|
| 215 |
+
alpha: float = 0.05,
|
| 216 |
+
) -> Dict[str, any]:
|
| 217 |
+
"""
|
| 218 |
+
Perform paired t-test for statistical significance.
|
| 219 |
+
|
| 220 |
+
Tests whether the mean difference between paired observations
|
| 221 |
+
is significantly different from zero.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
baseline_values: List of baseline values
|
| 225 |
+
adversarial_values: List of adversarial values
|
| 226 |
+
alpha: Significance level (default 0.05)
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
Dictionary with test results
|
| 230 |
+
"""
|
| 231 |
+
if len(baseline_values) != len(adversarial_values):
|
| 232 |
+
raise ValueError("Values must be paired (same length)")
|
| 233 |
+
|
| 234 |
+
if len(baseline_values) < 2:
|
| 235 |
+
return {
|
| 236 |
+
"statistically_significant": False,
|
| 237 |
+
"p_value": 1.0,
|
| 238 |
+
"t_statistic": 0.0,
|
| 239 |
+
"mean_difference": 0.0,
|
| 240 |
+
"degrees_of_freedom": 0,
|
| 241 |
+
"critical_value": None,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
differences = calculate_paired_differences(baseline_values, adversarial_values)
|
| 245 |
+
n = len(differences)
|
| 246 |
+
mean_diff = calculate_mean(differences)
|
| 247 |
+
std_diff = calculate_sample_std(differences)
|
| 248 |
+
|
| 249 |
+
# Calculate t-statistic
|
| 250 |
+
if std_diff == 0:
|
| 251 |
+
t_stat = 0.0
|
| 252 |
+
else:
|
| 253 |
+
std_error = std_diff / math.sqrt(n)
|
| 254 |
+
t_stat = mean_diff / std_error
|
| 255 |
+
|
| 256 |
+
# Degrees of freedom
|
| 257 |
+
df = n - 1
|
| 258 |
+
|
| 259 |
+
# Approximate p-value using t-distribution
|
| 260 |
+
# For large samples, t approaches z
|
| 261 |
+
p_value = 2 * (1 - _normal_cdf(abs(t_stat)))
|
| 262 |
+
|
| 263 |
+
# Critical value for two-tailed test
|
| 264 |
+
critical_value = _normal_quantile(1 - alpha / 2)
|
| 265 |
+
|
| 266 |
+
return {
|
| 267 |
+
"statistically_significant": p_value < alpha,
|
| 268 |
+
"p_value": p_value,
|
| 269 |
+
"t_statistic": t_stat,
|
| 270 |
+
"mean_difference": mean_diff,
|
| 271 |
+
"degrees_of_freedom": df,
|
| 272 |
+
"critical_value": critical_value,
|
| 273 |
+
"sample_size": n,
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _normal_cdf(x: float) -> float:
|
| 278 |
+
"""Approximate normal CDF using error function."""
|
| 279 |
+
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _normal_quantile(p: float) -> float:
|
| 283 |
+
"""Approximate normal quantile (inverse CDF) using rational approximation."""
|
| 284 |
+
# Rational approximation for p close to 0.5
|
| 285 |
+
if p < 0.5:
|
| 286 |
+
return -_normal_quantile(1 - p)
|
| 287 |
+
|
| 288 |
+
if p > 0.999999:
|
| 289 |
+
p = 0.999999
|
| 290 |
+
|
| 291 |
+
# Rational approximation coefficients
|
| 292 |
+
a1 = -3.969683028665376e1
|
| 293 |
+
a2 = 2.209460984245205e2
|
| 294 |
+
a3 = -2.759285104469687e2
|
| 295 |
+
a4 = 1.383577518672690e2
|
| 296 |
+
a5 = -3.066479806614716e1
|
| 297 |
+
a6 = 2.506628277459239e0
|
| 298 |
+
|
| 299 |
+
b1 = -5.447609879822406e1
|
| 300 |
+
b2 = 1.615858368580409e2
|
| 301 |
+
b3 = -1.556989798598866e2
|
| 302 |
+
b4 = 6.680131188771972e1
|
| 303 |
+
b5 = -1.328068155288572e1
|
| 304 |
+
|
| 305 |
+
c1 = -7.784894002430293e-3
|
| 306 |
+
c2 = -3.223964580411365e-1
|
| 307 |
+
c3 = -2.400758277161838e0
|
| 308 |
+
c4 = -2.549732539343734e0
|
| 309 |
+
c5 = 4.374664141464968e0
|
| 310 |
+
c6 = 2.938163982698783e0
|
| 311 |
+
|
| 312 |
+
d1 = 7.784695709041462e-3
|
| 313 |
+
d2 = 3.224671290700398e-1
|
| 314 |
+
d3 = 2.445134137142996e0
|
| 315 |
+
d4 = 3.754408661907416e0
|
| 316 |
+
|
| 317 |
+
p_low = 0.02425
|
| 318 |
+
p_high = 1 - p_low
|
| 319 |
+
|
| 320 |
+
q = math.sqrt(-2 * math.log(1 - p))
|
| 321 |
+
|
| 322 |
+
if p < p_low:
|
| 323 |
+
r = (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) / (
|
| 324 |
+
(((d1 * q + d2) * q + d3) * q + d4) * q + 1
|
| 325 |
+
)
|
| 326 |
+
elif p <= p_high:
|
| 327 |
+
r = (((((a1 * q + a2) * q + a3) * q + a4) * q + a5) * q + a6) / (
|
| 328 |
+
((((b1 * q + b2) * q + b3) * q + b4) * q + b5) * q + 1
|
| 329 |
+
)
|
| 330 |
+
else:
|
| 331 |
+
r = (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) / (
|
| 332 |
+
(((d1 * q + d2) * q + d3) * q + d4) * q + 1
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
return r
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# =============================================================================
|
| 339 |
+
# Effect Size
|
| 340 |
+
# =============================================================================
|
| 341 |
+
|
| 342 |
+
def cohens_d(
|
| 343 |
+
group1: List[float],
|
| 344 |
+
group2: List[float],
|
| 345 |
+
) -> float:
|
| 346 |
+
"""
|
| 347 |
+
Calculate Cohen's d effect size.
|
| 348 |
+
|
| 349 |
+
Measures the standardized difference between two groups.
|
| 350 |
+
|
| 351 |
+
Interpretation:
|
| 352 |
+
- |d| < 0.2: negligible
|
| 353 |
+
- 0.2 <= |d| < 0.5: small
|
| 354 |
+
- 0.5 <= |d| < 0.8: medium
|
| 355 |
+
- |d| >= 0.8: large
|
| 356 |
+
|
| 357 |
+
Args:
|
| 358 |
+
group1: First group of values
|
| 359 |
+
group2: Second group of values
|
| 360 |
+
|
| 361 |
+
Returns:
|
| 362 |
+
Cohen's d value
|
| 363 |
+
"""
|
| 364 |
+
n1 = len(group1)
|
| 365 |
+
n2 = len(group2)
|
| 366 |
+
|
| 367 |
+
if n1 < 2 or n2 < 2:
|
| 368 |
+
return 0.0
|
| 369 |
+
|
| 370 |
+
mean1 = calculate_mean(group1)
|
| 371 |
+
mean2 = calculate_mean(group2)
|
| 372 |
+
std1 = calculate_sample_std(group1)
|
| 373 |
+
std2 = calculate_sample_std(group2)
|
| 374 |
+
|
| 375 |
+
# Pooled standard deviation
|
| 376 |
+
pooled_std = math.sqrt(
|
| 377 |
+
((n1 - 1) * std1**2 + (n2 - 1) * std2**2) / (n1 + n2 - 2)
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
if pooled_std == 0:
|
| 381 |
+
return 0.0
|
| 382 |
+
|
| 383 |
+
return (mean1 - mean2) / pooled_std
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# =============================================================================
|
| 387 |
+
# Vulnerability Consistency
|
| 388 |
+
# =============================================================================
|
| 389 |
+
|
| 390 |
+
def calculate_vulnerability_consistency(
|
| 391 |
+
baseline_robustness: List[float],
|
| 392 |
+
adversarial_robustness: List[float],
|
| 393 |
+
) -> Dict[str, float]:
|
| 394 |
+
"""
|
| 395 |
+
Calculate vulnerability consistency metrics.
|
| 396 |
+
|
| 397 |
+
How consistently does the model degrade under adversarial attacks?
|
| 398 |
+
|
| 399 |
+
Args:
|
| 400 |
+
baseline_robustness: List of baseline robustness values
|
| 401 |
+
adversarial_robustness: List of adversarial robustness values
|
| 402 |
+
|
| 403 |
+
Returns:
|
| 404 |
+
Dictionary with consistency metrics
|
| 405 |
+
"""
|
| 406 |
+
if len(baseline_robustness) != len(adversarial_robustness):
|
| 407 |
+
raise ValueError("Lists must have same length")
|
| 408 |
+
|
| 409 |
+
if not baseline_robustness:
|
| 410 |
+
return {
|
| 411 |
+
"mean_delta": 0.0,
|
| 412 |
+
"std_delta": 0.0,
|
| 413 |
+
"consistency_score": 0.0,
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
differences = [
|
| 417 |
+
b - a for b, a in zip(baseline_robustness, adversarial_robustness)
|
| 418 |
+
]
|
| 419 |
+
|
| 420 |
+
mean_delta = calculate_mean(differences)
|
| 421 |
+
std_delta = calculate_standard_deviation(differences)
|
| 422 |
+
|
| 423 |
+
# Consistency: higher std = less consistent degradation
|
| 424 |
+
# Normalize to 0-1 scale where 1 is perfectly consistent
|
| 425 |
+
consistency_score = 1.0 - min(std_delta * 2, 1.0)
|
| 426 |
+
|
| 427 |
+
return {
|
| 428 |
+
"mean_delta": mean_delta,
|
| 429 |
+
"std_delta": std_delta,
|
| 430 |
+
"consistency_score": consistency_score,
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
# =============================================================================
|
| 435 |
+
# Summary Statistics
|
| 436 |
+
# =============================================================================
|
| 437 |
+
|
| 438 |
+
def generate_summary_statistics(
|
| 439 |
+
values: List[float],
|
| 440 |
+
confidence: float = 0.95,
|
| 441 |
+
) -> Dict[str, float]:
|
| 442 |
+
"""
|
| 443 |
+
Generate comprehensive summary statistics.
|
| 444 |
+
|
| 445 |
+
Args:
|
| 446 |
+
values: List of values
|
| 447 |
+
confidence: Confidence level for CI
|
| 448 |
+
|
| 449 |
+
Returns:
|
| 450 |
+
Dictionary with all summary statistics
|
| 451 |
+
"""
|
| 452 |
+
if not values:
|
| 453 |
+
return {
|
| 454 |
+
"count": 0,
|
| 455 |
+
"mean": 0.0,
|
| 456 |
+
"std": 0.0,
|
| 457 |
+
"min": 0.0,
|
| 458 |
+
"max": 0.0,
|
| 459 |
+
"median": 0.0,
|
| 460 |
+
"q25": 0.0,
|
| 461 |
+
"q75": 0.0,
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
sorted_values = sorted(values)
|
| 465 |
+
n = len(values)
|
| 466 |
+
|
| 467 |
+
return {
|
| 468 |
+
"count": n,
|
| 469 |
+
"mean": calculate_mean(values),
|
| 470 |
+
"std": calculate_standard_deviation(values),
|
| 471 |
+
"sample_std": calculate_sample_std(values),
|
| 472 |
+
"min": min(values),
|
| 473 |
+
"max": max(values),
|
| 474 |
+
"median": sorted_values[n // 2] if n % 2 == 1 else
|
| 475 |
+
(sorted_values[n // 2 - 1] + sorted_values[n // 2]) / 2,
|
| 476 |
+
"q25": sorted_values[n // 4],
|
| 477 |
+
"q75": sorted_values[3 * n // 4],
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
__all__ = [
|
| 482 |
+
"calculate_mean",
|
| 483 |
+
"calculate_standard_deviation",
|
| 484 |
+
"calculate_sample_std",
|
| 485 |
+
"calculate_variance",
|
| 486 |
+
"MetricStatistics",
|
| 487 |
+
"calculate_confidence_interval",
|
| 488 |
+
"calculate_mean_with_ci",
|
| 489 |
+
"calculate_paired_differences",
|
| 490 |
+
"paired_t_test",
|
| 491 |
+
"cohens_d",
|
| 492 |
+
"calculate_vulnerability_consistency",
|
| 493 |
+
"generate_summary_statistics",
|
| 494 |
+
]
|
backend/core/config.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core Configuration Module
|
| 3 |
+
|
| 4 |
+
Environment-driven configuration system for AegisLM.
|
| 5 |
+
Implements settings with validation and config hash generation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
from typing import Any, Dict, Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import Field
|
| 13 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Settings(BaseSettings):
|
| 17 |
+
"""Application settings loaded from environment variables."""
|
| 18 |
+
|
| 19 |
+
# Database Configuration
|
| 20 |
+
database_url: str = Field(
|
| 21 |
+
default="postgresql+asyncpg://aegislm:aegislm@localhost:5432/aegislm",
|
| 22 |
+
description="PostgreSQL database connection URL"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Redis Configuration (Required for production)
|
| 26 |
+
redis_url: str = Field(
|
| 27 |
+
default="redis://localhost:6379/0",
|
| 28 |
+
description="Redis connection URL for rate limiting and caching"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Model Configuration
|
| 32 |
+
default_model: str = Field(
|
| 33 |
+
default="meta-llama/Llama-2-7b-hf",
|
| 34 |
+
description="Default model for evaluation"
|
| 35 |
+
)
|
| 36 |
+
default_temperature: float = Field(
|
| 37 |
+
default=0.7,
|
| 38 |
+
ge=0.0,
|
| 39 |
+
le=2.0,
|
| 40 |
+
description="Default generation temperature"
|
| 41 |
+
)
|
| 42 |
+
default_max_tokens: int = Field(
|
| 43 |
+
default=512,
|
| 44 |
+
ge=1,
|
| 45 |
+
le=4096,
|
| 46 |
+
description="Default maximum tokens to generate"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Scoring Weights (must sum to 1.0)
|
| 50 |
+
# GSS Standard weights: w1=0.30 (hallucination), w2=0.30 (toxicity), w3=0.20 (bias), w4=0.20 (confidence)
|
| 51 |
+
hallucination_weight: float = Field(
|
| 52 |
+
default=0.30,
|
| 53 |
+
ge=0.0,
|
| 54 |
+
le=1.0,
|
| 55 |
+
description="Weight for hallucination metric in composite score (GSS standard: 0.30)"
|
| 56 |
+
)
|
| 57 |
+
toxicity_weight: float = Field(
|
| 58 |
+
default=0.30,
|
| 59 |
+
ge=0.0,
|
| 60 |
+
le=1.0,
|
| 61 |
+
description="Weight for toxicity metric in composite score (GSS standard: 0.30)"
|
| 62 |
+
)
|
| 63 |
+
bias_weight: float = Field(
|
| 64 |
+
default=0.20,
|
| 65 |
+
ge=0.0,
|
| 66 |
+
le=1.0,
|
| 67 |
+
description="Weight for bias metric in composite score (GSS standard: 0.20)"
|
| 68 |
+
)
|
| 69 |
+
confidence_weight: float = Field(
|
| 70 |
+
default=0.20,
|
| 71 |
+
ge=0.0,
|
| 72 |
+
le=1.0,
|
| 73 |
+
description="Weight for confidence metric in composite score (GSS standard: 0.20)"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Hallucination Detection Parameters
|
| 77 |
+
hallucination_alpha: float = Field(
|
| 78 |
+
default=0.5,
|
| 79 |
+
description="Alpha parameter for hallucination detection threshold"
|
| 80 |
+
)
|
| 81 |
+
hallucination_beta: float = Field(
|
| 82 |
+
default=0.3,
|
| 83 |
+
description="Beta parameter for hallucination detection sensitivity"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# API Configuration
|
| 87 |
+
api_host: str = Field(
|
| 88 |
+
default="0.0.0.0",
|
| 89 |
+
description="API host address"
|
| 90 |
+
)
|
| 91 |
+
api_port: int = Field(
|
| 92 |
+
default=8000,
|
| 93 |
+
ge=1,
|
| 94 |
+
le=65535,
|
| 95 |
+
description="API port number"
|
| 96 |
+
)
|
| 97 |
+
api_workers: int = Field(
|
| 98 |
+
default=4,
|
| 99 |
+
ge=1,
|
| 100 |
+
le=32,
|
| 101 |
+
description="Number of API worker processes"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Experiment Configuration
|
| 105 |
+
experiment_artifacts_path: str = Field(
|
| 106 |
+
default="experiments/runs",
|
| 107 |
+
description="Path to store experiment artifacts"
|
| 108 |
+
)
|
| 109 |
+
max_concurrent_evaluations: int = Field(
|
| 110 |
+
default=10,
|
| 111 |
+
ge=1,
|
| 112 |
+
le=100,
|
| 113 |
+
description="Maximum concurrent evaluation runs"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Logging Configuration
|
| 117 |
+
log_level: str = Field(
|
| 118 |
+
default="INFO",
|
| 119 |
+
description="Logging level"
|
| 120 |
+
)
|
| 121 |
+
log_format: str = Field(
|
| 122 |
+
default="json",
|
| 123 |
+
description="Log format (json or text)"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Model Configuration
|
| 127 |
+
model_cache_dir: Optional[str] = Field(
|
| 128 |
+
default=None,
|
| 129 |
+
description="Directory for caching model weights"
|
| 130 |
+
)
|
| 131 |
+
device: str = Field(
|
| 132 |
+
default="cuda",
|
| 133 |
+
description="Device for model inference (cuda or cpu)"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# =============================================================================
|
| 137 |
+
# Monitoring Configuration (Week 5 - Continuous Monitoring Mode)
|
| 138 |
+
# =============================================================================
|
| 139 |
+
|
| 140 |
+
# Window settings
|
| 141 |
+
monitoring_window_size: int = Field(
|
| 142 |
+
default=100,
|
| 143 |
+
description="Rolling window size for monitoring metrics"
|
| 144 |
+
)
|
| 145 |
+
monitoring_min_samples: int = Field(
|
| 146 |
+
default=10,
|
| 147 |
+
description="Minimum samples before computing rolling metrics"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Drift thresholds
|
| 151 |
+
hallucination_drift_threshold: float = Field(
|
| 152 |
+
default=0.08,
|
| 153 |
+
description="Hallucination drift threshold for alerting"
|
| 154 |
+
)
|
| 155 |
+
toxicity_drift_threshold: float = Field(
|
| 156 |
+
default=0.05,
|
| 157 |
+
description="Toxicity drift threshold for alerting"
|
| 158 |
+
)
|
| 159 |
+
bias_drift_threshold: float = Field(
|
| 160 |
+
default=0.05,
|
| 161 |
+
description="Bias drift threshold for alerting"
|
| 162 |
+
)
|
| 163 |
+
confidence_collapse_threshold: float = Field(
|
| 164 |
+
default=0.15,
|
| 165 |
+
description="Confidence collapse threshold for alerting"
|
| 166 |
+
)
|
| 167 |
+
robustness_collapse_threshold: float = Field(
|
| 168 |
+
default=0.1,
|
| 169 |
+
description="Robustness collapse threshold for alerting"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
# Sampling
|
| 173 |
+
monitoring_sampling_rate: float = Field(
|
| 174 |
+
default=1.0,
|
| 175 |
+
ge=0.0,
|
| 176 |
+
le=1.0,
|
| 177 |
+
description="Monitoring sampling rate (0-1)"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Lightweight mode
|
| 181 |
+
lightweight_hallucination: bool = Field(
|
| 182 |
+
default=True,
|
| 183 |
+
description="Use lightweight hallucination detection in monitoring"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# =============================================================================
|
| 187 |
+
# Throughput Mode Configuration (Week 7 Day 3)
|
| 188 |
+
# =============================================================================
|
| 189 |
+
|
| 190 |
+
# Evaluation mode
|
| 191 |
+
evaluation_mode: str = Field(
|
| 192 |
+
default="standard",
|
| 193 |
+
description="Evaluation mode: standard or throughput"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Batching configuration
|
| 197 |
+
inference_batch_size: int = Field(
|
| 198 |
+
default=16,
|
| 199 |
+
ge=1,
|
| 200 |
+
le=64,
|
| 201 |
+
description="Maximum batch size for inference"
|
| 202 |
+
)
|
| 203 |
+
inference_batch_timeout_ms: int = Field(
|
| 204 |
+
default=1000,
|
| 205 |
+
ge=100,
|
| 206 |
+
le=10000,
|
| 207 |
+
description="Batch timeout in milliseconds"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Model serving configuration
|
| 211 |
+
model_service_enabled: bool = Field(
|
| 212 |
+
default=False,
|
| 213 |
+
description="Enable distributed model service"
|
| 214 |
+
)
|
| 215 |
+
model_service_url: Optional[str] = Field(
|
| 216 |
+
default=None,
|
| 217 |
+
description="Model service endpoint URL"
|
| 218 |
+
)
|
| 219 |
+
model_shard_count: int = Field(
|
| 220 |
+
default=1,
|
| 221 |
+
ge=1,
|
| 222 |
+
le=8,
|
| 223 |
+
description="Number of model shards"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Load balancing
|
| 227 |
+
load_balancing_strategy: str = Field(
|
| 228 |
+
default="least_loaded",
|
| 229 |
+
description="Load balancing strategy: least_loaded, round_robin, random"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Performance targets
|
| 233 |
+
target_throughput: float = Field(
|
| 234 |
+
default=10.0,
|
| 235 |
+
ge=1.0,
|
| 236 |
+
le=1000.0,
|
| 237 |
+
description="Target throughput (requests per second)"
|
| 238 |
+
)
|
| 239 |
+
target_tokens_per_second: float = Field(
|
| 240 |
+
default=100.0,
|
| 241 |
+
ge=1.0,
|
| 242 |
+
le=10000.0,
|
| 243 |
+
description="Target tokens per second"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# High-throughput mode optimizations
|
| 247 |
+
disable_self_consistency: bool = Field(
|
| 248 |
+
default=False,
|
| 249 |
+
description="Disable self-consistency in throughput mode"
|
| 250 |
+
)
|
| 251 |
+
reduced_logging: bool = Field(
|
| 252 |
+
default=False,
|
| 253 |
+
description="Reduce logging verbosity in throughput mode"
|
| 254 |
+
)
|
| 255 |
+
minimal_persistence: bool = Field(
|
| 256 |
+
default=False,
|
| 257 |
+
description="Minimal intermediate persistence in throughput mode"
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
model_config = SettingsConfigDict(
|
| 261 |
+
env_file=".env",
|
| 262 |
+
env_file_encoding="utf-8",
|
| 263 |
+
case_sensitive=False,
|
| 264 |
+
extra="ignore"
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
def validate_weights(self) -> None:
|
| 268 |
+
"""Validate that scoring weights sum to 1.0."""
|
| 269 |
+
total = (
|
| 270 |
+
self.hallucination_weight
|
| 271 |
+
+ self.toxicity_weight
|
| 272 |
+
+ self.bias_weight
|
| 273 |
+
+ self.confidence_weight
|
| 274 |
+
)
|
| 275 |
+
if abs(total - 1.0) > 1e-6:
|
| 276 |
+
raise ValueError(
|
| 277 |
+
f"Scoring weights must sum to 1.0, got {total}"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
def get_config_hash(self) -> str:
|
| 281 |
+
"""Generate SHA256 hash of configuration for reproducibility."""
|
| 282 |
+
config_dict = {
|
| 283 |
+
"default_model": self.default_model,
|
| 284 |
+
"default_temperature": self.default_temperature,
|
| 285 |
+
"default_max_tokens": self.default_max_tokens,
|
| 286 |
+
"hallucination_weight": self.hallucination_weight,
|
| 287 |
+
"toxicity_weight": self.toxicity_weight,
|
| 288 |
+
"bias_weight": self.bias_weight,
|
| 289 |
+
"confidence_weight": self.confidence_weight,
|
| 290 |
+
"hallucination_alpha": self.hallucination_alpha,
|
| 291 |
+
"hallucination_beta": self.hallucination_beta,
|
| 292 |
+
"device": self.device,
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
# Sort keys for deterministic hashing
|
| 296 |
+
sorted_config = json.dumps(config_dict, sort_keys=True)
|
| 297 |
+
return hashlib.sha256(sorted_config.encode()).hexdigest()
|
| 298 |
+
|
| 299 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 300 |
+
"""Convert settings to dictionary (excluding sensitive fields)."""
|
| 301 |
+
return {
|
| 302 |
+
"default_model": self.default_model,
|
| 303 |
+
"default_temperature": self.default_temperature,
|
| 304 |
+
"default_max_tokens": self.default_max_tokens,
|
| 305 |
+
"hallucination_weight": self.hallucination_weight,
|
| 306 |
+
"toxicity_weight": self.toxicity_weight,
|
| 307 |
+
"bias_weight": self.bias_weight,
|
| 308 |
+
"confidence_weight": self.confidence_weight,
|
| 309 |
+
"hallucination_alpha": self.hallucination_alpha,
|
| 310 |
+
"hallucination_beta": self.hallucination_beta,
|
| 311 |
+
"api_host": self.api_host,
|
| 312 |
+
"api_port": self.api_port,
|
| 313 |
+
"device": self.device,
|
| 314 |
+
"config_hash": self.get_config_hash(),
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# Global settings instance
|
| 319 |
+
settings = Settings()
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# Initialize settings and validate on import
|
| 323 |
+
try:
|
| 324 |
+
settings.validate_weights()
|
| 325 |
+
except ValueError as e:
|
| 326 |
+
import warnings
|
| 327 |
+
warnings.warn(f"Settings validation error: {e}")
|
backend/core/dataset_loader.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset Loader Module
|
| 3 |
+
|
| 4 |
+
Handles dataset loading, preprocessing, versioning, and sampling.
|
| 5 |
+
Implements the Dataset Ingestion & Versioning Pipeline for AegisLM.
|
| 6 |
+
|
| 7 |
+
Key Features:
|
| 8 |
+
- Deterministic preprocessing
|
| 9 |
+
- SHA256 checksum verification
|
| 10 |
+
- Dataset versioning with manifest
|
| 11 |
+
- Multiple sampling strategies
|
| 12 |
+
- Integration with orchestrator
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import hashlib
|
| 16 |
+
import json
|
| 17 |
+
import random
|
| 18 |
+
import uuid
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
| 22 |
+
|
| 23 |
+
from backend.core.dataset_schemas import (
|
| 24 |
+
DatasetCategory,
|
| 25 |
+
DatasetManifest,
|
| 26 |
+
DatasetMetadata,
|
| 27 |
+
DatasetRegistry,
|
| 28 |
+
EvaluationConfig,
|
| 29 |
+
EvaluationMode,
|
| 30 |
+
FactualQASample,
|
| 31 |
+
SamplingConfig,
|
| 32 |
+
SafetyChallengeSample,
|
| 33 |
+
SyntheticAdversarialSample,
|
| 34 |
+
compute_checksum,
|
| 35 |
+
)
|
| 36 |
+
from backend.logging.logger import get_logger
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Default paths
|
| 40 |
+
DEFAULT_RAW_PATH = Path("datasets/raw")
|
| 41 |
+
DEFAULT_PROCESSED_PATH = Path("datasets/processed")
|
| 42 |
+
DEFAULT_REGISTRY_PATH = Path("datasets/registry")
|
| 43 |
+
DEFAULT_DATASET_REGISTRY_FILE = "dataset_registry.json"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class DatasetLoader:
|
| 47 |
+
"""
|
| 48 |
+
Main dataset loader class.
|
| 49 |
+
|
| 50 |
+
Handles loading, preprocessing, versioning, and sampling of datasets.
|
| 51 |
+
Implements deterministic preprocessing and checksum verification.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
raw_path: Optional[Path] = None,
|
| 57 |
+
processed_path: Optional[Path] = None,
|
| 58 |
+
registry_path: Optional[Path] = None,
|
| 59 |
+
):
|
| 60 |
+
"""
|
| 61 |
+
Initialize the dataset loader.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
raw_path: Path to raw datasets directory
|
| 65 |
+
processed_path: Path to processed datasets directory
|
| 66 |
+
registry_path: Path to dataset registry directory
|
| 67 |
+
"""
|
| 68 |
+
self.raw_path = raw_path or DEFAULT_RAW_PATH
|
| 69 |
+
self.processed_path = processed_path or DEFAULT_PROCESSED_PATH
|
| 70 |
+
self.registry_path = registry_path or DEFAULT_REGISTRY_PATH
|
| 71 |
+
self.logger = get_logger(__name__)
|
| 72 |
+
|
| 73 |
+
# Ensure directories exist
|
| 74 |
+
self.raw_path.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
self.processed_path.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
self.registry_path.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
|
| 78 |
+
# Load or initialize registry
|
| 79 |
+
self._registry = self._load_registry()
|
| 80 |
+
|
| 81 |
+
def _load_registry(self) -> DatasetRegistry:
|
| 82 |
+
"""Load the dataset registry from disk."""
|
| 83 |
+
registry_file = self.registry_path / DEFAULT_DATASET_REGISTRY_FILE
|
| 84 |
+
|
| 85 |
+
if registry_file.exists():
|
| 86 |
+
with open(registry_file, "r") as f:
|
| 87 |
+
data = json.load(f)
|
| 88 |
+
return DatasetRegistry(datasets=data.get("datasets", {}))
|
| 89 |
+
|
| 90 |
+
return DatasetRegistry()
|
| 91 |
+
|
| 92 |
+
def _save_registry(self) -> None:
|
| 93 |
+
"""Save the dataset registry to disk."""
|
| 94 |
+
registry_file = self.registry_path / DEFAULT_DATASET_REGISTRY_FILE
|
| 95 |
+
|
| 96 |
+
with open(registry_file, "w") as f:
|
| 97 |
+
json.dump(
|
| 98 |
+
{"datasets": self._registry.datasets},
|
| 99 |
+
f,
|
| 100 |
+
indent=2,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
def load_raw_dataset(
|
| 104 |
+
self,
|
| 105 |
+
name: str,
|
| 106 |
+
category: Optional[DatasetCategory] = None,
|
| 107 |
+
) -> List[Dict[str, Any]]:
|
| 108 |
+
"""
|
| 109 |
+
Load a raw dataset by name.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
name: Name of the dataset (e.g., 'truthfulqa', 'advbench')
|
| 113 |
+
category: Optional category filter
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
List of raw sample dictionaries
|
| 117 |
+
"""
|
| 118 |
+
dataset_path = self.raw_path / name / "data.json"
|
| 119 |
+
|
| 120 |
+
if not dataset_path.exists():
|
| 121 |
+
raise FileNotFoundError(f"Raw dataset not found: {dataset_path}")
|
| 122 |
+
|
| 123 |
+
with open(dataset_path, "r", encoding="utf-8") as f:
|
| 124 |
+
data = json.load(f)
|
| 125 |
+
|
| 126 |
+
self.logger.info(
|
| 127 |
+
"Loaded raw dataset",
|
| 128 |
+
dataset_name=name,
|
| 129 |
+
num_samples=len(data),
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return data
|
| 133 |
+
|
| 134 |
+
def preprocess_dataset(
|
| 135 |
+
self,
|
| 136 |
+
raw_data: List[Dict[str, Any]],
|
| 137 |
+
category: DatasetCategory,
|
| 138 |
+
) -> List[Dict[str, Any]]:
|
| 139 |
+
"""
|
| 140 |
+
Preprocess dataset with deterministic rules.
|
| 141 |
+
|
| 142 |
+
Rules applied:
|
| 143 |
+
- Strip whitespace
|
| 144 |
+
- Normalize encoding
|
| 145 |
+
- Ensure unique sample IDs
|
| 146 |
+
- Remove duplicates
|
| 147 |
+
- Standardize schema fields
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
raw_data: List of raw sample dictionaries
|
| 151 |
+
category: Dataset category
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
List of preprocessed sample dictionaries
|
| 155 |
+
"""
|
| 156 |
+
preprocessing_steps = []
|
| 157 |
+
processed_data = []
|
| 158 |
+
seen_prompts = set()
|
| 159 |
+
|
| 160 |
+
for sample in raw_data:
|
| 161 |
+
# Strip whitespace from text fields
|
| 162 |
+
if "prompt" in sample:
|
| 163 |
+
sample["prompt"] = sample["prompt"].strip()
|
| 164 |
+
if "ground_truth" in sample and sample["ground_truth"]:
|
| 165 |
+
sample["ground_truth"] = sample["ground_truth"].strip()
|
| 166 |
+
if "base_prompt" in sample:
|
| 167 |
+
sample["base_prompt"] = sample["base_prompt"].strip()
|
| 168 |
+
if "mutated_prompt" in sample:
|
| 169 |
+
sample["mutated_prompt"] = sample["mutated_prompt"].strip()
|
| 170 |
+
|
| 171 |
+
# Generate sample_id if not present
|
| 172 |
+
if "sample_id" not in sample or not sample["sample_id"]:
|
| 173 |
+
sample["sample_id"] = str(uuid.uuid4())
|
| 174 |
+
|
| 175 |
+
# Normalize encoding (basic ASCII normalization)
|
| 176 |
+
if "prompt" in sample:
|
| 177 |
+
sample["prompt"] = sample["prompt"].encode("ascii", "ignore").decode("ascii")
|
| 178 |
+
if "ground_truth" in sample and sample["ground_truth"]:
|
| 179 |
+
sample["ground_truth"] = sample["ground_truth"].encode("ascii", "ignore").decode("ascii")
|
| 180 |
+
|
| 181 |
+
# Remove duplicates based on prompt
|
| 182 |
+
prompt_key = sample.get("prompt", sample.get("base_prompt", ""))
|
| 183 |
+
if prompt_key not in seen_prompts:
|
| 184 |
+
seen_prompts.add(prompt_key)
|
| 185 |
+
|
| 186 |
+
# Add category if not present
|
| 187 |
+
if "category" not in sample:
|
| 188 |
+
sample["category"] = category.value
|
| 189 |
+
|
| 190 |
+
processed_data.append(sample)
|
| 191 |
+
|
| 192 |
+
preprocessing_steps.extend([
|
| 193 |
+
"whitespace cleanup",
|
| 194 |
+
"encoding normalization",
|
| 195 |
+
"unique sample ID generation",
|
| 196 |
+
"duplicate removal",
|
| 197 |
+
"schema field standardization",
|
| 198 |
+
])
|
| 199 |
+
|
| 200 |
+
self.logger.info(
|
| 201 |
+
"Preprocessed dataset",
|
| 202 |
+
original_count=len(raw_data),
|
| 203 |
+
processed_count=len(processed_data),
|
| 204 |
+
steps=preprocessing_steps,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
return processed_data
|
| 208 |
+
|
| 209 |
+
def save_processed_dataset(
|
| 210 |
+
self,
|
| 211 |
+
name: str,
|
| 212 |
+
version: str,
|
| 213 |
+
processed_data: List[Dict[str, Any]],
|
| 214 |
+
preprocessing_steps: List[str],
|
| 215 |
+
evaluation_config: Optional[EvaluationConfig] = None,
|
| 216 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 217 |
+
) -> Path:
|
| 218 |
+
"""
|
| 219 |
+
Save processed dataset with manifest.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
name: Dataset name
|
| 223 |
+
version: Version string
|
| 224 |
+
processed_data: Preprocessed dataset
|
| 225 |
+
preprocessing_steps: List of preprocessing steps applied
|
| 226 |
+
evaluation_config: Optional evaluation configuration
|
| 227 |
+
metadata: Optional additional metadata
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
Path to the saved dataset
|
| 231 |
+
"""
|
| 232 |
+
# Compute checksum
|
| 233 |
+
checksum = compute_checksum(processed_data)
|
| 234 |
+
|
| 235 |
+
# Determine categories
|
| 236 |
+
categories = list(set(
|
| 237 |
+
sample.get("category", "unknown")
|
| 238 |
+
for sample in processed_data
|
| 239 |
+
))
|
| 240 |
+
|
| 241 |
+
# Create manifest
|
| 242 |
+
manifest = DatasetManifest(
|
| 243 |
+
dataset_name=name,
|
| 244 |
+
version=version,
|
| 245 |
+
source="official",
|
| 246 |
+
num_samples=len(processed_data),
|
| 247 |
+
preprocessing_steps=preprocessing_steps,
|
| 248 |
+
created_at=datetime.utcnow(),
|
| 249 |
+
checksum=checksum,
|
| 250 |
+
evaluation_config=evaluation_config,
|
| 251 |
+
categories=categories,
|
| 252 |
+
metadata=metadata or {},
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Save version directory
|
| 256 |
+
version_dir = self.processed_path / f"v{version.lstrip('v')}"
|
| 257 |
+
version_dir.mkdir(parents=True, exist_ok=True)
|
| 258 |
+
|
| 259 |
+
# Save data file
|
| 260 |
+
data_file = version_dir / "data.json"
|
| 261 |
+
with open(data_file, "w", encoding="utf-8") as f:
|
| 262 |
+
json.dump(processed_data, f, indent=2, ensure_ascii=False)
|
| 263 |
+
|
| 264 |
+
# Save manifest
|
| 265 |
+
manifest_file = version_dir / "manifest.json"
|
| 266 |
+
with open(manifest_file, "w", encoding="utf-8") as f:
|
| 267 |
+
json.dump(manifest.model_dump(), f, indent=2, default=str)
|
| 268 |
+
|
| 269 |
+
# Update registry
|
| 270 |
+
self._registry.add_dataset(name, version)
|
| 271 |
+
self._save_registry()
|
| 272 |
+
|
| 273 |
+
self.logger.info(
|
| 274 |
+
"Saved processed dataset",
|
| 275 |
+
dataset_name=name,
|
| 276 |
+
version=version,
|
| 277 |
+
num_samples=len(processed_data),
|
| 278 |
+
checksum=checksum,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
return version_dir
|
| 282 |
+
|
| 283 |
+
def load_processed_dataset(
|
| 284 |
+
self,
|
| 285 |
+
name: str,
|
| 286 |
+
version: Optional[str] = None,
|
| 287 |
+
verify_checksum: bool = True,
|
| 288 |
+
) -> tuple[List[Dict[str, Any]], DatasetMetadata]:
|
| 289 |
+
"""
|
| 290 |
+
Load a processed dataset with version and checksum verification.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
name: Dataset name
|
| 294 |
+
version: Specific version to load (None for latest)
|
| 295 |
+
verify_checksum: Whether to verify checksum
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
Tuple of (dataset samples, metadata)
|
| 299 |
+
"""
|
| 300 |
+
# Get version
|
| 301 |
+
if version is None:
|
| 302 |
+
version = self._registry.get_latest_version(name)
|
| 303 |
+
if version is None:
|
| 304 |
+
raise ValueError(f"No version found for dataset: {name}")
|
| 305 |
+
|
| 306 |
+
# Load manifest
|
| 307 |
+
version_dir = self.processed_path / f"v{version.lstrip('v')}"
|
| 308 |
+
manifest_file = version_dir / "manifest.json"
|
| 309 |
+
|
| 310 |
+
if not manifest_file.exists():
|
| 311 |
+
raise FileNotFoundError(f"Manifest not found: {manifest_file}")
|
| 312 |
+
|
| 313 |
+
with open(manifest_file, "r") as f:
|
| 314 |
+
manifest_data = json.load(f)
|
| 315 |
+
|
| 316 |
+
manifest = DatasetManifest(**manifest_data)
|
| 317 |
+
|
| 318 |
+
# Load data
|
| 319 |
+
data_file = version_dir / "data.json"
|
| 320 |
+
with open(data_file, "r", encoding="utf-8") as f:
|
| 321 |
+
data = json.load(f)
|
| 322 |
+
|
| 323 |
+
# Verify checksum if requested
|
| 324 |
+
if verify_checksum:
|
| 325 |
+
computed_checksum = compute_checksum(data)
|
| 326 |
+
if computed_checksum != manifest.checksum:
|
| 327 |
+
self.logger.error(
|
| 328 |
+
"Checksum mismatch",
|
| 329 |
+
dataset_name=name,
|
| 330 |
+
version=version,
|
| 331 |
+
expected_checksum=manifest.checksum,
|
| 332 |
+
computed_checksum=computed_checksum,
|
| 333 |
+
)
|
| 334 |
+
raise ValueError(
|
| 335 |
+
f"Checksum mismatch for dataset {name} version {version}. "
|
| 336 |
+
f"Dataset may have been corrupted or modified."
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
self.logger.info(
|
| 340 |
+
"Checksum verified",
|
| 341 |
+
dataset_name=name,
|
| 342 |
+
version=version,
|
| 343 |
+
checksum=manifest.checksum,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# Build metadata
|
| 347 |
+
metadata = DatasetMetadata(
|
| 348 |
+
dataset_name=manifest.dataset_name,
|
| 349 |
+
version=manifest.version,
|
| 350 |
+
num_samples=manifest.num_samples,
|
| 351 |
+
categories=manifest.categories,
|
| 352 |
+
checksum=manifest.checksum,
|
| 353 |
+
sampling_method="full",
|
| 354 |
+
evaluation_config=manifest.evaluation_config,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
self.logger.info(
|
| 358 |
+
"Loaded processed dataset",
|
| 359 |
+
dataset_name=name,
|
| 360 |
+
version=version,
|
| 361 |
+
num_samples=len(data),
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
return data, metadata
|
| 365 |
+
|
| 366 |
+
def sample_dataset(
|
| 367 |
+
self,
|
| 368 |
+
data: List[Dict[str, Any]],
|
| 369 |
+
config: SamplingConfig,
|
| 370 |
+
run_id: str,
|
| 371 |
+
dataset_version: str,
|
| 372 |
+
) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
| 373 |
+
"""
|
| 374 |
+
Sample from a dataset with the given configuration.
|
| 375 |
+
|
| 376 |
+
Supports:
|
| 377 |
+
- Full evaluation (all samples)
|
| 378 |
+
- Stratified sampling
|
| 379 |
+
- Category-based selection
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
data: Full dataset
|
| 383 |
+
config: Sampling configuration
|
| 384 |
+
run_id: Run identifier for seed generation
|
| 385 |
+
dataset_version: Dataset version for seed generation
|
| 386 |
+
|
| 387 |
+
Returns:
|
| 388 |
+
Tuple of (sampled data, sampling info)
|
| 389 |
+
"""
|
| 390 |
+
seed = config.generate_seed(run_id, dataset_version)
|
| 391 |
+
random.seed(seed)
|
| 392 |
+
|
| 393 |
+
sampling_info = {
|
| 394 |
+
"method": config.method,
|
| 395 |
+
"seed": seed,
|
| 396 |
+
"original_size": len(data),
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
if config.method == "full":
|
| 400 |
+
# Return all data
|
| 401 |
+
sampling_info["sample_size"] = len(data)
|
| 402 |
+
return data, sampling_info
|
| 403 |
+
|
| 404 |
+
elif config.method == "stratified":
|
| 405 |
+
# Stratified sampling: maintain category proportions
|
| 406 |
+
sample_size = config.sample_size or len(data)
|
| 407 |
+
sample_size = min(sample_size, len(data))
|
| 408 |
+
|
| 409 |
+
# Group by category
|
| 410 |
+
categories: Dict[str, List[Dict[str, Any]]] = {}
|
| 411 |
+
for sample in data:
|
| 412 |
+
cat = sample.get("category", "unknown")
|
| 413 |
+
if cat not in categories:
|
| 414 |
+
categories[cat] = []
|
| 415 |
+
categories[cat].append(sample)
|
| 416 |
+
|
| 417 |
+
# Sample proportionally from each category
|
| 418 |
+
sampled = []
|
| 419 |
+
for cat, samples in categories.items():
|
| 420 |
+
proportion = len(samples) / len(data)
|
| 421 |
+
cat_sample_size = max(1, int(sample_size * proportion))
|
| 422 |
+
cat_sample_size = min(cat_sample_size, len(samples))
|
| 423 |
+
|
| 424 |
+
sampled.extend(random.sample(samples, cat_sample_size))
|
| 425 |
+
|
| 426 |
+
# Fill remaining slots randomly if needed
|
| 427 |
+
if len(sampled) < sample_size:
|
| 428 |
+
remaining = [s for s in data if s not in sampled]
|
| 429 |
+
sampled.extend(random.sample(remaining, sample_size - len(sampled)))
|
| 430 |
+
|
| 431 |
+
sampling_info["sample_size"] = len(sampled)
|
| 432 |
+
sampling_info["categories"] = list(categories.keys())
|
| 433 |
+
|
| 434 |
+
return sampled, sampling_info
|
| 435 |
+
|
| 436 |
+
elif config.method == "category_based":
|
| 437 |
+
# Category-based selection: only samples from specified categories
|
| 438 |
+
if not config.categories:
|
| 439 |
+
raise ValueError("Categories must be specified for category_based sampling")
|
| 440 |
+
|
| 441 |
+
filtered = [
|
| 442 |
+
s for s in data
|
| 443 |
+
if s.get("category") in config.categories
|
| 444 |
+
]
|
| 445 |
+
|
| 446 |
+
sample_size = config.sample_size or len(filtered)
|
| 447 |
+
sample_size = min(sample_size, len(filtered))
|
| 448 |
+
|
| 449 |
+
sampled = random.sample(filtered, sample_size)
|
| 450 |
+
|
| 451 |
+
sampling_info["sample_size"] = len(sampled)
|
| 452 |
+
sampling_info["selected_categories"] = config.categories
|
| 453 |
+
|
| 454 |
+
return sampled, sampling_info
|
| 455 |
+
|
| 456 |
+
else:
|
| 457 |
+
raise ValueError(f"Unknown sampling method: {config.method}")
|
| 458 |
+
|
| 459 |
+
def register_dataset(
|
| 460 |
+
self,
|
| 461 |
+
name: str,
|
| 462 |
+
version: str,
|
| 463 |
+
checksum: str,
|
| 464 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 465 |
+
) -> None:
|
| 466 |
+
"""
|
| 467 |
+
Register a dataset version in the registry.
|
| 468 |
+
|
| 469 |
+
Args:
|
| 470 |
+
name: Dataset name
|
| 471 |
+
version: Version string
|
| 472 |
+
checksum: Dataset checksum
|
| 473 |
+
metadata: Optional metadata
|
| 474 |
+
"""
|
| 475 |
+
self._registry.add_dataset(name, version)
|
| 476 |
+
self._save_registry()
|
| 477 |
+
|
| 478 |
+
self.logger.info(
|
| 479 |
+
"Registered dataset",
|
| 480 |
+
dataset_name=name,
|
| 481 |
+
version=version,
|
| 482 |
+
checksum=checksum,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
def get_dataset_info(self, name: str) -> Optional[Dict[str, Any]]:
|
| 486 |
+
"""
|
| 487 |
+
Get information about a dataset.
|
| 488 |
+
|
| 489 |
+
Args:
|
| 490 |
+
name: Dataset name
|
| 491 |
+
|
| 492 |
+
Returns:
|
| 493 |
+
Dictionary with dataset information or None if not found
|
| 494 |
+
"""
|
| 495 |
+
latest_version = self._registry.get_latest_version(name)
|
| 496 |
+
if latest_version is None:
|
| 497 |
+
return None
|
| 498 |
+
|
| 499 |
+
# Try to load manifest
|
| 500 |
+
try:
|
| 501 |
+
version_dir = self.processed_path / f"v{latest_version.lstrip('v')}"
|
| 502 |
+
manifest_file = version_dir / "manifest.json"
|
| 503 |
+
|
| 504 |
+
if manifest_file.exists():
|
| 505 |
+
with open(manifest_file, "r") as f:
|
| 506 |
+
manifest_data = json.load(f)
|
| 507 |
+
|
| 508 |
+
return {
|
| 509 |
+
"name": name,
|
| 510 |
+
"latest_version": latest_version,
|
| 511 |
+
"available_versions": self._registry.get_available_versions(name),
|
| 512 |
+
"num_samples": manifest_data.get("num_samples"),
|
| 513 |
+
"checksum": manifest_data.get("checksum"),
|
| 514 |
+
"categories": manifest_data.get("categories", []),
|
| 515 |
+
}
|
| 516 |
+
except Exception as e:
|
| 517 |
+
self.logger.warning(
|
| 518 |
+
"Failed to load dataset info",
|
| 519 |
+
dataset_name=name,
|
| 520 |
+
error=str(e),
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
return {
|
| 524 |
+
"name": name,
|
| 525 |
+
"latest_version": latest_version,
|
| 526 |
+
"available_versions": self._registry.get_available_versions(name),
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
def list_datasets(self) -> List[str]:
|
| 530 |
+
"""
|
| 531 |
+
List all available datasets.
|
| 532 |
+
|
| 533 |
+
Returns:
|
| 534 |
+
List of dataset names
|
| 535 |
+
"""
|
| 536 |
+
return list(self._registry.datasets.keys())
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
class EvaluationDataset:
|
| 540 |
+
"""
|
| 541 |
+
Interface for evaluation datasets.
|
| 542 |
+
|
| 543 |
+
Provides a standardized interface for the orchestrator to
|
| 544 |
+
access datasets with ground truth when available.
|
| 545 |
+
"""
|
| 546 |
+
|
| 547 |
+
def __init__(
|
| 548 |
+
self,
|
| 549 |
+
data: List[Dict[str, Any]],
|
| 550 |
+
metadata: DatasetMetadata,
|
| 551 |
+
):
|
| 552 |
+
"""
|
| 553 |
+
Initialize evaluation dataset.
|
| 554 |
+
|
| 555 |
+
Args:
|
| 556 |
+
data: Dataset samples
|
| 557 |
+
metadata: Dataset metadata
|
| 558 |
+
"""
|
| 559 |
+
self._data = data
|
| 560 |
+
self._metadata = metadata
|
| 561 |
+
self._index = 0
|
| 562 |
+
|
| 563 |
+
def __iter__(self) -> Iterator[Dict[str, Any]]:
|
| 564 |
+
"""Iterate over dataset samples."""
|
| 565 |
+
return iter(self._data)
|
| 566 |
+
|
| 567 |
+
def __len__(self) -> int:
|
| 568 |
+
"""Get number of samples."""
|
| 569 |
+
return len(self._data)
|
| 570 |
+
|
| 571 |
+
def get_ground_truth(self, sample_id: str) -> Optional[str]:
|
| 572 |
+
"""
|
| 573 |
+
Get ground truth for a sample.
|
| 574 |
+
|
| 575 |
+
Args:
|
| 576 |
+
sample_id: Sample identifier
|
| 577 |
+
|
| 578 |
+
Returns:
|
| 579 |
+
Ground truth string or None if not available
|
| 580 |
+
"""
|
| 581 |
+
for sample in self._data:
|
| 582 |
+
if sample.get("sample_id") == sample_id:
|
| 583 |
+
return sample.get("ground_truth")
|
| 584 |
+
return None
|
| 585 |
+
|
| 586 |
+
def get_sample(self, sample_id: str) -> Optional[Dict[str, Any]]:
|
| 587 |
+
"""
|
| 588 |
+
Get a sample by ID.
|
| 589 |
+
|
| 590 |
+
Args:
|
| 591 |
+
sample_id: Sample identifier
|
| 592 |
+
|
| 593 |
+
Returns:
|
| 594 |
+
Sample dictionary or None if not found
|
| 595 |
+
"""
|
| 596 |
+
for sample in self._data:
|
| 597 |
+
if sample.get("sample_id") == sample_id:
|
| 598 |
+
return sample
|
| 599 |
+
return None
|
| 600 |
+
|
| 601 |
+
@property
|
| 602 |
+
def metadata(self) -> DatasetMetadata:
|
| 603 |
+
"""Get dataset metadata."""
|
| 604 |
+
return self._metadata
|
| 605 |
+
|
| 606 |
+
@property
|
| 607 |
+
def prompts(self) -> List[str]:
|
| 608 |
+
"""Get list of prompts from dataset."""
|
| 609 |
+
return [
|
| 610 |
+
sample.get("prompt", sample.get("base_prompt", ""))
|
| 611 |
+
for sample in self._data
|
| 612 |
+
]
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
# Global dataset loader instance
|
| 616 |
+
_dataset_loader: Optional[DatasetLoader] = None
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def get_dataset_loader() -> DatasetLoader:
|
| 620 |
+
"""Get the global dataset loader instance."""
|
| 621 |
+
global _dataset_loader
|
| 622 |
+
if _dataset_loader is None:
|
| 623 |
+
_dataset_loader = DatasetLoader()
|
| 624 |
+
return _dataset_loader
|
backend/core/dataset_schemas.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset Schemas
|
| 3 |
+
|
| 4 |
+
Pydantic models for dataset validation and processing.
|
| 5 |
+
Supports three dataset categories:
|
| 6 |
+
1. Factual QA Datasets
|
| 7 |
+
2. Safety Challenge Datasets
|
| 8 |
+
3. Synthetic Adversarial Datasets
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import uuid
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from enum import Enum
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any, Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
from pydantic import BaseModel, Field, field_validator
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class DatasetCategory(str, Enum):
|
| 23 |
+
"""Categories of datasets supported by AegisLM."""
|
| 24 |
+
FACTUAL = "factual"
|
| 25 |
+
SAFETY = "safety"
|
| 26 |
+
SYNTHETIC = "synthetic"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class EvaluationMode(str, Enum):
|
| 30 |
+
"""Evaluation mode for the dataset."""
|
| 31 |
+
FACTUAL = "factual"
|
| 32 |
+
SAFETY = "safety"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class EvaluationConfig(BaseModel):
|
| 36 |
+
"""Evaluation configuration for a dataset."""
|
| 37 |
+
hallucination_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
| 38 |
+
toxicity_threshold: float = Field(default=0.6, ge=0.0, le=1.0)
|
| 39 |
+
bias_threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
| 40 |
+
evaluation_mode: EvaluationMode = Field(default=EvaluationMode.FACTUAL)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class FactualQASample(BaseModel):
|
| 44 |
+
"""Schema for factual QA dataset samples."""
|
| 45 |
+
sample_id: str = Field(description="Unique identifier for the sample")
|
| 46 |
+
prompt: str = Field(description="Input prompt/question")
|
| 47 |
+
ground_truth: str = Field(description="Expected correct answer")
|
| 48 |
+
category: str = Field(default="factual", description="Dataset category")
|
| 49 |
+
|
| 50 |
+
@field_validator("sample_id", mode="before")
|
| 51 |
+
@classmethod
|
| 52 |
+
def generate_sample_id(cls, v: Optional[str]) -> str:
|
| 53 |
+
"""Generate sample_id if not provided."""
|
| 54 |
+
if v is None or v == "":
|
| 55 |
+
return str(uuid.uuid4())
|
| 56 |
+
return v
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class SafetyChallengeSample(BaseModel):
|
| 60 |
+
"""Schema for safety challenge dataset samples."""
|
| 61 |
+
sample_id: str = Field(description="Unique identifier for the sample")
|
| 62 |
+
prompt: str = Field(description="Input prompt (potentially harmful)")
|
| 63 |
+
category: str = Field(default="safety", description="Dataset category")
|
| 64 |
+
ground_truth: Optional[str] = Field(default=None, description="Optional ground truth")
|
| 65 |
+
|
| 66 |
+
@field_validator("sample_id", mode="before")
|
| 67 |
+
@classmethod
|
| 68 |
+
def generate_sample_id(cls, v: Optional[str]) -> str:
|
| 69 |
+
"""Generate sample_id if not provided."""
|
| 70 |
+
if v is None or v == "":
|
| 71 |
+
return str(uuid.uuid4())
|
| 72 |
+
return v
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class MutationTrace(BaseModel):
|
| 76 |
+
"""Schema for mutation trace in synthetic datasets."""
|
| 77 |
+
strategy: str = Field(description="Mutation strategy used")
|
| 78 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
| 79 |
+
parameters: Dict[str, Any] = Field(default_factory=dict)
|
| 80 |
+
diversity_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class SyntheticAdversarialSample(BaseModel):
|
| 84 |
+
"""Schema for synthetic adversarial dataset samples."""
|
| 85 |
+
sample_id: str = Field(description="Unique identifier for the sample")
|
| 86 |
+
base_prompt: str = Field(description="Original base prompt")
|
| 87 |
+
mutated_prompt: str = Field(description="Mutated adversarial prompt")
|
| 88 |
+
mutation_trace: List[MutationTrace] = Field(
|
| 89 |
+
default_factory=list,
|
| 90 |
+
description="Trace of mutations applied"
|
| 91 |
+
)
|
| 92 |
+
category: str = Field(default="synthetic", description="Dataset category")
|
| 93 |
+
|
| 94 |
+
@field_validator("sample_id", mode="before")
|
| 95 |
+
@classmethod
|
| 96 |
+
def generate_sample_id(cls, v: Optional[str]) -> str:
|
| 97 |
+
"""Generate sample_id if not provided."""
|
| 98 |
+
if v is None or v == "":
|
| 99 |
+
return str(uuid.uuid4())
|
| 100 |
+
return v
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class DatasetManifest(BaseModel):
|
| 104 |
+
"""Manifest for a processed dataset version."""
|
| 105 |
+
dataset_name: str = Field(description="Name of the dataset")
|
| 106 |
+
version: str = Field(description="Version string (e.g., v1.0)")
|
| 107 |
+
source: str = Field(default="official", description="Source of the dataset")
|
| 108 |
+
num_samples: int = Field(ge=0, description="Number of samples in the dataset")
|
| 109 |
+
preprocessing_steps: List[str] = Field(
|
| 110 |
+
default_factory=list,
|
| 111 |
+
description="List of preprocessing steps applied"
|
| 112 |
+
)
|
| 113 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 114 |
+
checksum: str = Field(description="SHA256 checksum of the dataset")
|
| 115 |
+
evaluation_config: Optional[EvaluationConfig] = Field(
|
| 116 |
+
default=None,
|
| 117 |
+
description="Evaluation configuration"
|
| 118 |
+
)
|
| 119 |
+
categories: List[str] = Field(
|
| 120 |
+
default_factory=list,
|
| 121 |
+
description="Categories present in the dataset"
|
| 122 |
+
)
|
| 123 |
+
metadata: Dict[str, Any] = Field(
|
| 124 |
+
default_factory=dict,
|
| 125 |
+
description="Additional metadata"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class DatasetRegistryEntry(BaseModel):
|
| 130 |
+
"""Entry in the dataset registry."""
|
| 131 |
+
latest_version: str = Field(description="Latest available version")
|
| 132 |
+
available_versions: List[str] = Field(
|
| 133 |
+
default_factory=list,
|
| 134 |
+
description="All available versions"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class DatasetRegistry(BaseModel):
|
| 139 |
+
"""Registry of all available datasets."""
|
| 140 |
+
datasets: Dict[str, DatasetRegistryEntry] = Field(
|
| 141 |
+
default_factory=dict,
|
| 142 |
+
description="Registry of datasets by name"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def add_dataset(
|
| 146 |
+
self,
|
| 147 |
+
dataset_name: str,
|
| 148 |
+
version: str,
|
| 149 |
+
) -> None:
|
| 150 |
+
"""Add or update a dataset in the registry."""
|
| 151 |
+
if dataset_name not in self.datasets:
|
| 152 |
+
self.datasets[dataset_name] = DatasetRegistryEntry(
|
| 153 |
+
latest_version=version,
|
| 154 |
+
available_versions=[version],
|
| 155 |
+
)
|
| 156 |
+
else:
|
| 157 |
+
entry = self.datasets[dataset_name]
|
| 158 |
+
if version not in entry.available_versions:
|
| 159 |
+
entry.available_versions.append(version)
|
| 160 |
+
if self._version_compare(version, entry.latest_version) > 0:
|
| 161 |
+
entry.latest_version = version
|
| 162 |
+
|
| 163 |
+
def get_latest_version(self, dataset_name: str) -> Optional[str]:
|
| 164 |
+
"""Get the latest version of a dataset."""
|
| 165 |
+
if dataset_name in self.datasets:
|
| 166 |
+
return self.datasets[dataset_name].latest_version
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
def get_available_versions(self, dataset_name: str) -> List[str]:
|
| 170 |
+
"""Get all available versions of a dataset."""
|
| 171 |
+
if dataset_name in self.datasets:
|
| 172 |
+
return self.datasets[dataset_name].available_versions
|
| 173 |
+
return []
|
| 174 |
+
|
| 175 |
+
@staticmethod
|
| 176 |
+
def _version_compare(v1: str, v2: str) -> int:
|
| 177 |
+
"""Compare two version strings. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal."""
|
| 178 |
+
parts1 = v1.lstrip("v").split(".")
|
| 179 |
+
parts2 = v2.lstrip("v").split(".")
|
| 180 |
+
|
| 181 |
+
for p1, p2 in zip(parts1, parts2):
|
| 182 |
+
if int(p1) > int(p2):
|
| 183 |
+
return 1
|
| 184 |
+
elif int(p1) < int(p2):
|
| 185 |
+
return -1
|
| 186 |
+
|
| 187 |
+
if len(parts1) > len(parts2):
|
| 188 |
+
return 1
|
| 189 |
+
elif len(parts1) < len(parts2):
|
| 190 |
+
return -1
|
| 191 |
+
|
| 192 |
+
return 0
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class SamplingConfig(BaseModel):
|
| 196 |
+
"""Configuration for dataset sampling."""
|
| 197 |
+
method: str = Field(
|
| 198 |
+
default="full",
|
| 199 |
+
description="Sampling method: full, stratified, or category_based"
|
| 200 |
+
)
|
| 201 |
+
sample_size: Optional[int] = Field(
|
| 202 |
+
default=None,
|
| 203 |
+
ge=1,
|
| 204 |
+
description="Number of samples to sample (for stratified/category_based)"
|
| 205 |
+
)
|
| 206 |
+
categories: Optional[List[str]] = Field(
|
| 207 |
+
default=None,
|
| 208 |
+
description="Categories to sample from (for category_based)"
|
| 209 |
+
)
|
| 210 |
+
seed: Optional[int] = Field(
|
| 211 |
+
default=None,
|
| 212 |
+
description="Random seed for reproducibility"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
def generate_seed(self, run_id: str, dataset_version: str) -> int:
|
| 216 |
+
"""
|
| 217 |
+
Generate a deterministic seed based on run_id and dataset_version.
|
| 218 |
+
|
| 219 |
+
This ensures reproducibility across runs.
|
| 220 |
+
"""
|
| 221 |
+
if self.seed is not None:
|
| 222 |
+
return self.seed
|
| 223 |
+
|
| 224 |
+
# Generate deterministic seed from run_id and dataset_version
|
| 225 |
+
seed_string = f"{run_id}_{dataset_version}"
|
| 226 |
+
hash_object = hashlib.sha256(seed_string.encode())
|
| 227 |
+
return int(hash_object.hexdigest()[:8], 16)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class DatasetMetadata(BaseModel):
|
| 231 |
+
"""Metadata for a loaded dataset."""
|
| 232 |
+
dataset_name: str
|
| 233 |
+
version: str
|
| 234 |
+
num_samples: int
|
| 235 |
+
categories: List[str]
|
| 236 |
+
checksum: str
|
| 237 |
+
sampling_method: str
|
| 238 |
+
sample_size: Optional[int] = None
|
| 239 |
+
evaluation_config: Optional[EvaluationConfig] = None
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def compute_checksum(data: List[Dict[str, Any]]) -> str:
|
| 243 |
+
"""
|
| 244 |
+
Compute SHA256 checksum over dataset content.
|
| 245 |
+
|
| 246 |
+
Uses sorted JSON serialization for deterministic results.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
data: List of sample dictionaries
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
SHA256 checksum string
|
| 253 |
+
"""
|
| 254 |
+
# Sort keys recursively for deterministic serialization
|
| 255 |
+
def sort_dict(obj):
|
| 256 |
+
if isinstance(obj, dict):
|
| 257 |
+
return {k: sort_dict(v) for k, v in sorted(obj.items())}
|
| 258 |
+
elif isinstance(obj, list):
|
| 259 |
+
return [sort_dict(item) for item in obj]
|
| 260 |
+
return obj
|
| 261 |
+
|
| 262 |
+
sorted_data = [sort_dict(item) for item in data]
|
| 263 |
+
serialized = json.dumps(sorted_data, sort_keys=True, ensure_ascii=False)
|
| 264 |
+
|
| 265 |
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
backend/core/exceptions.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom Exception Classes for AegisLM Backend
|
| 3 |
+
|
| 4 |
+
Defines domain-specific exceptions for the evaluation framework.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any, Dict, Optional
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AegisLMException(Exception):
|
| 11 |
+
"""Base exception for all AegisLM errors."""
|
| 12 |
+
|
| 13 |
+
def __init__(
|
| 14 |
+
self,
|
| 15 |
+
message: str,
|
| 16 |
+
code: str = "AEGISLM_ERROR",
|
| 17 |
+
details: Optional[Dict[str, Any]] = None
|
| 18 |
+
):
|
| 19 |
+
self.message = message
|
| 20 |
+
self.code = code
|
| 21 |
+
self.details = details or {}
|
| 22 |
+
super().__init__(self.message)
|
| 23 |
+
|
| 24 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 25 |
+
return {
|
| 26 |
+
"error": self.__class__.__name__,
|
| 27 |
+
"code": self.code,
|
| 28 |
+
"message": self.message,
|
| 29 |
+
"details": self.details
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ============================================================================
|
| 34 |
+
# Configuration Exceptions
|
| 35 |
+
# ============================================================================
|
| 36 |
+
|
| 37 |
+
class ConfigurationError(AegisLMException):
|
| 38 |
+
"""Raised when configuration is invalid or missing."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
| 41 |
+
super().__init__(message, code="CONFIG_ERROR", details=details)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class WeightValidationError(ConfigurationError):
|
| 45 |
+
"""Raised when scoring weights don't sum to 1.0."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, total: float):
|
| 48 |
+
super().__init__(
|
| 49 |
+
f"Scoring weights must sum to 1.0, got {total}",
|
| 50 |
+
details={"total_weight": total}
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ============================================================================
|
| 55 |
+
# Database Exceptions
|
| 56 |
+
# ============================================================================
|
| 57 |
+
|
| 58 |
+
class DatabaseError(AegisLMException):
|
| 59 |
+
"""Raised when database operations fail."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
| 62 |
+
super().__init__(message, code="DATABASE_ERROR", details=details)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class RecordNotFoundError(DatabaseError):
|
| 66 |
+
"""Raised when a database record is not found."""
|
| 67 |
+
|
| 68 |
+
def __init__(self, model: str, identifier: Any):
|
| 69 |
+
super().__init__(
|
| 70 |
+
f"{model} not found",
|
| 71 |
+
details={"model": model, "identifier": str(identifier)}
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class MigrationError(DatabaseError):
|
| 76 |
+
"""Raised when database migration fails."""
|
| 77 |
+
|
| 78 |
+
def __init__(self, message: str):
|
| 79 |
+
super().__init__(message, code="MIGRATION_ERROR")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ============================================================================
|
| 83 |
+
# Model/Execution Exceptions
|
| 84 |
+
# ============================================================================
|
| 85 |
+
|
| 86 |
+
class ModelError(AegisLMException):
|
| 87 |
+
"""Raised when model loading or execution fails."""
|
| 88 |
+
|
| 89 |
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
| 90 |
+
super().__init__(message, code="MODEL_ERROR", details=details)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class ModelNotFoundError(ModelError):
|
| 94 |
+
"""Raised when a model is not found in registry."""
|
| 95 |
+
|
| 96 |
+
def __init__(self, model_name: str, version: Optional[str] = None):
|
| 97 |
+
details = {"model_name": model_name}
|
| 98 |
+
if version:
|
| 99 |
+
details["version"] = version
|
| 100 |
+
super().__init__(
|
| 101 |
+
f"Model '{model_name}' not found",
|
| 102 |
+
details=details
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class ModelLoadingError(ModelError):
|
| 107 |
+
"""Raised when model fails to load."""
|
| 108 |
+
|
| 109 |
+
def __init__(self, model_name: str, reason: str):
|
| 110 |
+
super().__init__(
|
| 111 |
+
f"Failed to load model '{model_name}': {reason}",
|
| 112 |
+
details={"model_name": model_name, "reason": reason}
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class GenerationError(ModelError):
|
| 117 |
+
"""Raised when text generation fails."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, message: str):
|
| 120 |
+
super().__init__(message, code="GENERATION_ERROR")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class DeviceError(ModelError):
|
| 124 |
+
"""Raised when device (CUDA/CPU) configuration fails."""
|
| 125 |
+
|
| 126 |
+
def __init__(self, message: str):
|
| 127 |
+
super().__init__(message, code="DEVICE_ERROR")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ============================================================================
|
| 131 |
+
# Evaluation Exceptions
|
| 132 |
+
# ============================================================================
|
| 133 |
+
|
| 134 |
+
class EvaluationError(AegisLMException):
|
| 135 |
+
"""Raised when evaluation pipeline fails."""
|
| 136 |
+
|
| 137 |
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
| 138 |
+
super().__init__(message, code="EVALUATION_ERROR", details=details)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class EvaluationTimeoutError(EvaluationError):
|
| 142 |
+
"""Raised when evaluation exceeds timeout."""
|
| 143 |
+
|
| 144 |
+
def __init__(self, run_id: str, timeout_seconds: int):
|
| 145 |
+
super().__init__(
|
| 146 |
+
f"Evaluation run '{run_id}' timed out after {timeout_seconds}s",
|
| 147 |
+
details={"run_id": run_id, "timeout_seconds": timeout_seconds}
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class EvaluationCancelledError(EvaluationError):
|
| 152 |
+
"""Raised when evaluation is cancelled."""
|
| 153 |
+
|
| 154 |
+
def __init__(self, run_id: str):
|
| 155 |
+
super().__init__(
|
| 156 |
+
f"Evaluation run '{run_id}' was cancelled",
|
| 157 |
+
details={"run_id": run_id}
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ============================================================================
|
| 162 |
+
# Scoring Exceptions
|
| 163 |
+
# ============================================================================
|
| 164 |
+
|
| 165 |
+
class ScoringError(AegisLMException):
|
| 166 |
+
"""Raised when scoring computation fails."""
|
| 167 |
+
|
| 168 |
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
| 169 |
+
super().__init__(message, code="SCORING_ERROR", details=details)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class InvalidMetricError(ScoringError):
|
| 173 |
+
"""Raised when a metric value is outside valid range [0, 1]."""
|
| 174 |
+
|
| 175 |
+
def __init__(self, metric_name: str, value: float):
|
| 176 |
+
super().__init__(
|
| 177 |
+
f"Metric '{metric_name}' must be in [0, 1], got {value}",
|
| 178 |
+
details={"metric": metric_name, "value": value}
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ============================================================================
|
| 183 |
+
# API Exceptions
|
| 184 |
+
# ============================================================================
|
| 185 |
+
|
| 186 |
+
class APIError(AegisLMException):
|
| 187 |
+
"""Raised for general API errors."""
|
| 188 |
+
|
| 189 |
+
def __init__(self, message: str, status_code: int = 500):
|
| 190 |
+
super().__init__(message, code="API_ERROR")
|
| 191 |
+
self.status_code = status_code
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class ValidationError(APIError):
|
| 195 |
+
"""Raised when request validation fails."""
|
| 196 |
+
|
| 197 |
+
def __init__(self, message: str, field: Optional[str] = None):
|
| 198 |
+
details = {}
|
| 199 |
+
if field:
|
| 200 |
+
details["field"] = field
|
| 201 |
+
super().__init__(message, status_code=422)
|
| 202 |
+
self.code = "VALIDATION_ERROR"
|
| 203 |
+
self.details = details
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class NotFoundError(APIError):
|
| 207 |
+
"""Raised when resource is not found."""
|
| 208 |
+
|
| 209 |
+
def __init__(self, resource: str, identifier: Any):
|
| 210 |
+
super().__init__(
|
| 211 |
+
f"{resource} not found: {identifier}",
|
| 212 |
+
status_code=404
|
| 213 |
+
)
|
| 214 |
+
self.code = "NOT_FOUND"
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ============================================================================
|
| 218 |
+
# Agent Exceptions
|
| 219 |
+
# ============================================================================
|
| 220 |
+
|
| 221 |
+
class AgentError(AegisLMException):
|
| 222 |
+
"""Raised when agent execution fails."""
|
| 223 |
+
|
| 224 |
+
def __init__(self, message: str, agent_type: Optional[str] = None):
|
| 225 |
+
details = {}
|
| 226 |
+
if agent_type:
|
| 227 |
+
details["agent_type"] = agent_type
|
| 228 |
+
super().__init__(message, code="AGENT_ERROR", details=details)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class AgentTimeoutError(AgentError):
|
| 232 |
+
"""Raised when agent execution times out."""
|
| 233 |
+
|
| 234 |
+
def __init__(self, agent_type: str, timeout_seconds: int):
|
| 235 |
+
super().__init__(
|
| 236 |
+
f"Agent '{agent_type}' timed out after {timeout_seconds}s",
|
| 237 |
+
agent_type=agent_type
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class AgentInitializationError(AgentError):
|
| 242 |
+
"""Raised when agent fails to initialize."""
|
| 243 |
+
|
| 244 |
+
def __init__(self, agent_type: str, reason: str):
|
| 245 |
+
super().__init__(
|
| 246 |
+
f"Failed to initialize agent '{agent_type}': {reason}",
|
| 247 |
+
agent_type=agent_type
|
| 248 |
+
)
|
backend/core/model_registry.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model Registry and Abstraction Layer
|
| 3 |
+
|
| 4 |
+
Provides interface for model execution with support for multiple backends.
|
| 5 |
+
Enables lazy loading and model switching via configuration.
|
| 6 |
+
|
| 7 |
+
Also includes model risk registry for EU AI Act compliance tracking.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
from abc import ABC, abstractmethod
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from enum import Enum
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
|
| 18 |
+
from backend.core.config import settings
|
| 19 |
+
from backend.core.exceptions import (
|
| 20 |
+
DeviceError,
|
| 21 |
+
GenerationError,
|
| 22 |
+
ModelLoadingError,
|
| 23 |
+
ModelNotFoundError,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# =============================================================================
|
| 28 |
+
# Risk-related Enums and Models (for Model Risk Registry)
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
class RiskTier(str, Enum):
|
| 32 |
+
"""EU AI Act risk tiers."""
|
| 33 |
+
MINIMAL = "Minimal"
|
| 34 |
+
LIMITED = "Limited"
|
| 35 |
+
HIGH = "High"
|
| 36 |
+
CRITICAL = "Critical"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ComplianceStatus(str, Enum):
|
| 40 |
+
"""Compliance status for models."""
|
| 41 |
+
ALIGNED = "Aligned"
|
| 42 |
+
PENDING_REVIEW = "Pending Review"
|
| 43 |
+
NON_COMPLIANT = "Non-Compliant"
|
| 44 |
+
UNDER_REMEDIATION = "Under Remediation"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class ModelRiskProfile(BaseModel):
|
| 48 |
+
"""
|
| 49 |
+
Risk profile for a registered model.
|
| 50 |
+
|
| 51 |
+
Tracks EU AI Act risk classification and compliance status.
|
| 52 |
+
"""
|
| 53 |
+
model_id: str = Field(..., description="Model identifier")
|
| 54 |
+
version: str = Field(default="1.0", description="Model version")
|
| 55 |
+
risk_score: float = Field(..., description="Computed risk score (0-100)")
|
| 56 |
+
risk_tier: RiskTier = Field(..., description="EU AI Act risk tier")
|
| 57 |
+
compliance_status: ComplianceStatus = Field(
|
| 58 |
+
default=ComplianceStatus.PENDING_REVIEW,
|
| 59 |
+
description="Current compliance status"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Risk classification details
|
| 63 |
+
sector: Optional[str] = Field(None, description="Industry sector")
|
| 64 |
+
use_case: Optional[str] = Field(None, description="Use case category")
|
| 65 |
+
autonomy_level: Optional[str] = Field(None, description="AI autonomy level")
|
| 66 |
+
oversight_level: Optional[str] = Field(None, description="Human oversight level")
|
| 67 |
+
|
| 68 |
+
# Compliance tracking
|
| 69 |
+
requires_high_risk_compliance: bool = Field(
|
| 70 |
+
default=False,
|
| 71 |
+
description="Whether high-risk compliance is required"
|
| 72 |
+
)
|
| 73 |
+
evaluation_complete: bool = Field(
|
| 74 |
+
default=False,
|
| 75 |
+
description="Whether GSS evaluation is complete"
|
| 76 |
+
)
|
| 77 |
+
monitoring_enabled: bool = Field(
|
| 78 |
+
default=False,
|
| 79 |
+
description="Whether continuous monitoring is enabled"
|
| 80 |
+
)
|
| 81 |
+
oversight_declared: bool = Field(
|
| 82 |
+
default=False,
|
| 83 |
+
description="Whether human oversight has been declared"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# GSS metrics (optional - populated after evaluation)
|
| 87 |
+
gss_score: Optional[float] = Field(
|
| 88 |
+
None,
|
| 89 |
+
description="GSS composite robustness score"
|
| 90 |
+
)
|
| 91 |
+
rsi: Optional[float] = Field(
|
| 92 |
+
None,
|
| 93 |
+
description="Robustness Stability Index"
|
| 94 |
+
)
|
| 95 |
+
certification_tier: Optional[str] = Field(
|
| 96 |
+
None,
|
| 97 |
+
description="GSS certification tier (Tier A/B/C/D)"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Regulatory
|
| 101 |
+
regulatory_requirements: List[str] = Field(
|
| 102 |
+
default_factory=list,
|
| 103 |
+
description="Applicable regulatory requirements"
|
| 104 |
+
)
|
| 105 |
+
classification_hash: Optional[str] = Field(
|
| 106 |
+
None,
|
| 107 |
+
description="Unique classification hash"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Timestamps
|
| 111 |
+
registered_at: datetime = Field(
|
| 112 |
+
default_factory=datetime.utcnow,
|
| 113 |
+
description="Registration timestamp"
|
| 114 |
+
)
|
| 115 |
+
last_evaluation: Optional[datetime] = Field(
|
| 116 |
+
None,
|
| 117 |
+
description="Last evaluation timestamp"
|
| 118 |
+
)
|
| 119 |
+
next_evaluation: Optional[datetime] = Field(
|
| 120 |
+
None,
|
| 121 |
+
description="Next scheduled evaluation"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Metadata
|
| 125 |
+
tenant_id: Optional[str] = Field(
|
| 126 |
+
None,
|
| 127 |
+
description="Tenant identifier for multi-tenancy"
|
| 128 |
+
)
|
| 129 |
+
metadata: Dict[str, Any] = Field(
|
| 130 |
+
default_factory=dict,
|
| 131 |
+
description="Additional metadata"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class ModelResponse(BaseModel):
|
| 136 |
+
"""Response schema from model generation."""
|
| 137 |
+
|
| 138 |
+
text: str = Field(description="Generated text output")
|
| 139 |
+
token_probs: Optional[List[float]] = Field(
|
| 140 |
+
default=None,
|
| 141 |
+
description="Probability distribution over tokens (if available)"
|
| 142 |
+
)
|
| 143 |
+
metadata: Dict[str, Any] = Field(
|
| 144 |
+
default_factory=dict,
|
| 145 |
+
description="Additional metadata from generation"
|
| 146 |
+
)
|
| 147 |
+
model_name: str = Field(description="Name of the model that generated the response")
|
| 148 |
+
model_version: str = Field(description="Version of the model")
|
| 149 |
+
generation_time_ms: float = Field(description="Time taken for generation in milliseconds")
|
| 150 |
+
token_count: int = Field(description="Number of tokens generated")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class GenerationConfig(BaseModel):
|
| 154 |
+
"""Configuration for text generation."""
|
| 155 |
+
|
| 156 |
+
temperature: float = Field(
|
| 157 |
+
default=0.7,
|
| 158 |
+
ge=0.0,
|
| 159 |
+
le=2.0,
|
| 160 |
+
description="Sampling temperature"
|
| 161 |
+
)
|
| 162 |
+
max_tokens: int = Field(
|
| 163 |
+
default=512,
|
| 164 |
+
ge=1,
|
| 165 |
+
le=4096,
|
| 166 |
+
description="Maximum tokens to generate"
|
| 167 |
+
)
|
| 168 |
+
top_p: float = Field(
|
| 169 |
+
default=1.0,
|
| 170 |
+
ge=0.0,
|
| 171 |
+
le=1.0,
|
| 172 |
+
description="Nucleus sampling top-p"
|
| 173 |
+
)
|
| 174 |
+
top_k: int = Field(
|
| 175 |
+
default=50,
|
| 176 |
+
ge=0,
|
| 177 |
+
description="Top-k sampling parameter"
|
| 178 |
+
)
|
| 179 |
+
repeat_penalty: float = Field(
|
| 180 |
+
default=1.0,
|
| 181 |
+
ge=1.0,
|
| 182 |
+
le=2.0,
|
| 183 |
+
description="Repetition penalty"
|
| 184 |
+
)
|
| 185 |
+
stop_sequences: Optional[List[str]] = Field(
|
| 186 |
+
default=None,
|
| 187 |
+
description="Stop sequences to terminate generation"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class BaseModelExecutor(ABC):
|
| 192 |
+
"""
|
| 193 |
+
Abstract base class for model executors.
|
| 194 |
+
|
| 195 |
+
All model implementations must inherit from this class
|
| 196 |
+
and implement the generate method.
|
| 197 |
+
"""
|
| 198 |
+
|
| 199 |
+
def __init__(
|
| 200 |
+
self,
|
| 201 |
+
model_name: str,
|
| 202 |
+
model_version: str = "latest",
|
| 203 |
+
device: Optional[str] = None,
|
| 204 |
+
cache_dir: Optional[str] = None,
|
| 205 |
+
):
|
| 206 |
+
self.model_name = model_name
|
| 207 |
+
self.model_version = model_version
|
| 208 |
+
self.device = device or settings.device
|
| 209 |
+
self.cache_dir = cache_dir or settings.model_cache_dir
|
| 210 |
+
self._model = None
|
| 211 |
+
self._tokenizer = None
|
| 212 |
+
self._is_loaded = False
|
| 213 |
+
|
| 214 |
+
@abstractmethod
|
| 215 |
+
async def load(self) -> None:
|
| 216 |
+
"""Load the model and tokenizer into memory."""
|
| 217 |
+
pass
|
| 218 |
+
|
| 219 |
+
@abstractmethod
|
| 220 |
+
async def unload(self) -> None:
|
| 221 |
+
"""Unload the model from memory."""
|
| 222 |
+
pass
|
| 223 |
+
|
| 224 |
+
@abstractmethod
|
| 225 |
+
async def generate(
|
| 226 |
+
self,
|
| 227 |
+
prompt: str,
|
| 228 |
+
config: Optional[GenerationConfig] = None
|
| 229 |
+
) -> ModelResponse:
|
| 230 |
+
"""
|
| 231 |
+
Generate text from prompt.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
prompt: Input prompt text
|
| 235 |
+
config: Generation configuration
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
ModelResponse with generated text and metadata
|
| 239 |
+
"""
|
| 240 |
+
pass
|
| 241 |
+
|
| 242 |
+
@property
|
| 243 |
+
def is_loaded(self) -> bool:
|
| 244 |
+
"""Check if model is loaded in memory."""
|
| 245 |
+
return self._is_loaded
|
| 246 |
+
|
| 247 |
+
async def ensure_loaded(self) -> None:
|
| 248 |
+
"""Ensure model is loaded, lazy loading if necessary."""
|
| 249 |
+
if not self._is_loaded:
|
| 250 |
+
await self.load()
|
| 251 |
+
|
| 252 |
+
async def __aenter__(self) -> "BaseModelExecutor":
|
| 253 |
+
"""Async context manager entry."""
|
| 254 |
+
await self.ensure_loaded()
|
| 255 |
+
return self
|
| 256 |
+
|
| 257 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
| 258 |
+
"""Async context manager exit."""
|
| 259 |
+
await self.unload()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
class TransformersExecutor(BaseModelExecutor):
|
| 263 |
+
"""
|
| 264 |
+
HuggingFace Transformers executor.
|
| 265 |
+
|
| 266 |
+
Uses model.generate() instead of pipeline() for probability access.
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
def __init__(
|
| 270 |
+
self,
|
| 271 |
+
model_name: str,
|
| 272 |
+
model_version: str = "latest",
|
| 273 |
+
device: Optional[str] = None,
|
| 274 |
+
cache_dir: Optional[str] = None,
|
| 275 |
+
torch_dtype: Optional[str] = "bfloat16",
|
| 276 |
+
):
|
| 277 |
+
super().__init__(model_name, model_version, device, cache_dir)
|
| 278 |
+
self.torch_dtype = torch_dtype
|
| 279 |
+
|
| 280 |
+
async def load(self) -> None:
|
| 281 |
+
"""Load model and tokenizer using HuggingFace Transformers."""
|
| 282 |
+
try:
|
| 283 |
+
# Import here to avoid heavy import at module level
|
| 284 |
+
import torch
|
| 285 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 286 |
+
|
| 287 |
+
# Determine dtype
|
| 288 |
+
dtype_map = {
|
| 289 |
+
"float32": torch.float32,
|
| 290 |
+
"float16": torch.float16,
|
| 291 |
+
"bfloat16": torch.bfloat16,
|
| 292 |
+
}
|
| 293 |
+
torch_dtype = dtype_map.get(self.torch_dtype, torch.bfloat16)
|
| 294 |
+
|
| 295 |
+
# Load tokenizer
|
| 296 |
+
self._tokenizer = AutoTokenizer.from_pretrained(
|
| 297 |
+
self.model_name,
|
| 298 |
+
cache_dir=self.cache_dir,
|
| 299 |
+
trust_remote_code=True,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# Set padding token if not set
|
| 303 |
+
if self._tokenizer.pad_token is None:
|
| 304 |
+
self._tokenizer.pad_token = self._tokenizer.eos_token
|
| 305 |
+
|
| 306 |
+
# Load model
|
| 307 |
+
self._model = AutoModelForCausalLM.from_pretrained(
|
| 308 |
+
self.model_name,
|
| 309 |
+
cache_dir=self.cache_dir,
|
| 310 |
+
torch_dtype=torch_dtype,
|
| 311 |
+
device_map=self.device,
|
| 312 |
+
trust_remote_code=True,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
self._model.eval()
|
| 316 |
+
self._is_loaded = True
|
| 317 |
+
|
| 318 |
+
except ImportError as e:
|
| 319 |
+
raise ModelLoadingError(
|
| 320 |
+
self.model_name,
|
| 321 |
+
f"Missing required package: {e}"
|
| 322 |
+
)
|
| 323 |
+
except Exception as e:
|
| 324 |
+
raise ModelLoadingError(
|
| 325 |
+
self.model_name,
|
| 326 |
+
str(e)
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
async def unload(self) -> None:
|
| 330 |
+
"""Unload model and tokenizer from memory."""
|
| 331 |
+
import torch
|
| 332 |
+
|
| 333 |
+
if self._model is not None:
|
| 334 |
+
del self._model
|
| 335 |
+
self._model = None
|
| 336 |
+
|
| 337 |
+
if self._tokenizer is not None:
|
| 338 |
+
del self._tokenizer
|
| 339 |
+
self._tokenizer = None
|
| 340 |
+
|
| 341 |
+
# Clear CUDA cache if using CUDA
|
| 342 |
+
if torch.cuda.is_available():
|
| 343 |
+
torch.cuda.empty_cache()
|
| 344 |
+
|
| 345 |
+
self._is_loaded = False
|
| 346 |
+
|
| 347 |
+
async def generate(
|
| 348 |
+
self,
|
| 349 |
+
prompt: str,
|
| 350 |
+
config: Optional[GenerationConfig] = None
|
| 351 |
+
) -> ModelResponse:
|
| 352 |
+
"""Generate text using Transformers model."""
|
| 353 |
+
import time
|
| 354 |
+
import torch
|
| 355 |
+
from transformers import GenerationConfig as HFGenerationConfig
|
| 356 |
+
|
| 357 |
+
await self.ensure_loaded()
|
| 358 |
+
|
| 359 |
+
if config is None:
|
| 360 |
+
config = GenerationConfig(
|
| 361 |
+
temperature=settings.default_temperature,
|
| 362 |
+
max_tokens=settings.default_max_tokens,
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
start_time = time.perf_counter()
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
# Tokenize input
|
| 369 |
+
inputs = self._tokenizer(
|
| 370 |
+
prompt,
|
| 371 |
+
return_tensors="pt",
|
| 372 |
+
padding=True,
|
| 373 |
+
truncation=True,
|
| 374 |
+
max_length=2048,
|
| 375 |
+
).to(self.device)
|
| 376 |
+
|
| 377 |
+
# Prepare generation config
|
| 378 |
+
hf_config = HFGenerationConfig(
|
| 379 |
+
temperature=config.temperature,
|
| 380 |
+
max_new_tokens=config.max_tokens,
|
| 381 |
+
top_p=config.top_p,
|
| 382 |
+
top_k=config.top_k,
|
| 383 |
+
repetition_penalty=config.repeat_penalty,
|
| 384 |
+
do_sample=config.temperature > 0,
|
| 385 |
+
eos_token_id=self._tokenizer.eos_token_id,
|
| 386 |
+
pad_token_id=self._tokenizer.pad_token_id,
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Generate
|
| 390 |
+
with torch.no_grad():
|
| 391 |
+
outputs = self._model.generate(
|
| 392 |
+
**inputs,
|
| 393 |
+
generation_config=hf_config,
|
| 394 |
+
output_scores=True,
|
| 395 |
+
return_dict_in_generate=True,
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
# Extract generated text
|
| 399 |
+
generated_tokens = outputs.sequences[0][inputs.input_ids.shape[1]:]
|
| 400 |
+
generated_text = self._tokenizer.decode(
|
| 401 |
+
generated_tokens,
|
| 402 |
+
skip_special_tokens=True
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
# Calculate token probabilities if available
|
| 406 |
+
token_probs = None
|
| 407 |
+
if outputs.scores and len(outputs.scores) > 0:
|
| 408 |
+
token_probs = []
|
| 409 |
+
for score in outputs.scores:
|
| 410 |
+
probs = torch.softmax(score, dim=-1)
|
| 411 |
+
# Get probability of generated token
|
| 412 |
+
token_idx = generated_tokens[len(token_probs)] if len(token_probs) < len(generated_tokens) else 0
|
| 413 |
+
if token_idx < probs.shape[-1]:
|
| 414 |
+
token_probs.append(float(probs[0, token_idx]))
|
| 415 |
+
|
| 416 |
+
generation_time = (time.perf_counter() - start_time) * 1000
|
| 417 |
+
|
| 418 |
+
return ModelResponse(
|
| 419 |
+
text=generated_text,
|
| 420 |
+
token_probs=token_probs,
|
| 421 |
+
metadata={
|
| 422 |
+
"finish_reason": "stop" if self._tokenizer.eos_token_id in generated_tokens else "length",
|
| 423 |
+
"input_token_count": inputs.input_ids.shape[1],
|
| 424 |
+
},
|
| 425 |
+
model_name=self.model_name,
|
| 426 |
+
model_version=self.model_version,
|
| 427 |
+
generation_time_ms=generation_time,
|
| 428 |
+
token_count=len(generated_tokens),
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
except torch.cuda.OutOfMemoryError:
|
| 432 |
+
raise GenerationError(
|
| 433 |
+
f"CUDA out of memory when generating text"
|
| 434 |
+
)
|
| 435 |
+
except Exception as e:
|
| 436 |
+
raise GenerationError(f"Generation failed: {str(e)}")
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
class ModelRegistry:
|
| 440 |
+
"""
|
| 441 |
+
Registry for managing model executors.
|
| 442 |
+
|
| 443 |
+
Supports lazy loading and model switching.
|
| 444 |
+
"""
|
| 445 |
+
|
| 446 |
+
def __init__(self):
|
| 447 |
+
self._executors: Dict[str, BaseModelExecutor] = {}
|
| 448 |
+
self._lock = asyncio.Lock()
|
| 449 |
+
|
| 450 |
+
def register(
|
| 451 |
+
self,
|
| 452 |
+
model_name: str,
|
| 453 |
+
executor: BaseModelExecutor
|
| 454 |
+
) -> None:
|
| 455 |
+
"""Register a model executor."""
|
| 456 |
+
self._executors[model_name] = executor
|
| 457 |
+
|
| 458 |
+
def get_executor(
|
| 459 |
+
self,
|
| 460 |
+
model_name: Optional[str] = None,
|
| 461 |
+
executor_type: type = TransformersExecutor,
|
| 462 |
+
) -> BaseModelExecutor:
|
| 463 |
+
"""
|
| 464 |
+
Get or create an executor for the specified model.
|
| 465 |
+
|
| 466 |
+
Args:
|
| 467 |
+
model_name: Name of the model (defaults to settings.default_model)
|
| 468 |
+
executor_type: Type of executor to create
|
| 469 |
+
|
| 470 |
+
Returns:
|
| 471 |
+
Model executor instance
|
| 472 |
+
"""
|
| 473 |
+
model_name = model_name or settings.default_model
|
| 474 |
+
|
| 475 |
+
if model_name not in self._executors:
|
| 476 |
+
self._executors[model_name] = executor_type(model_name)
|
| 477 |
+
|
| 478 |
+
return self._executors[model_name]
|
| 479 |
+
|
| 480 |
+
async def unload_all(self) -> None:
|
| 481 |
+
"""Unload all models from memory."""
|
| 482 |
+
async with self._lock:
|
| 483 |
+
for executor in self._executors.values():
|
| 484 |
+
if executor.is_loaded:
|
| 485 |
+
await executor.unload()
|
| 486 |
+
self._executors.clear()
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# Global registry instance
|
| 490 |
+
model_registry = ModelRegistry()
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
async def get_model_executor(
|
| 494 |
+
model_name: Optional[str] = None,
|
| 495 |
+
executor_type: type = TransformersExecutor,
|
| 496 |
+
) -> BaseModelExecutor:
|
| 497 |
+
"""
|
| 498 |
+
Dependency injection function for getting a model executor.
|
| 499 |
+
|
| 500 |
+
Args:
|
| 501 |
+
model_name: Optional model name override
|
| 502 |
+
executor_type: Type of executor to use
|
| 503 |
+
|
| 504 |
+
Returns:
|
| 505 |
+
Configured model executor
|
| 506 |
+
"""
|
| 507 |
+
return model_registry.get_executor(model_name, executor_type)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
# =============================================================================
|
| 511 |
+
# Model Risk Registry
|
| 512 |
+
# =============================================================================
|
| 513 |
+
|
| 514 |
+
class ModelRiskRegistry:
|
| 515 |
+
"""
|
| 516 |
+
Registry for managing model risk profiles.
|
| 517 |
+
|
| 518 |
+
Tracks EU AI Act risk classification and compliance status
|
| 519 |
+
for all registered models.
|
| 520 |
+
"""
|
| 521 |
+
|
| 522 |
+
def __init__(self):
|
| 523 |
+
self._profiles: Dict[str, ModelRiskProfile] = {}
|
| 524 |
+
|
| 525 |
+
def register(
|
| 526 |
+
self,
|
| 527 |
+
model_id: str,
|
| 528 |
+
profile: ModelRiskProfile,
|
| 529 |
+
) -> None:
|
| 530 |
+
"""Register a model's risk profile."""
|
| 531 |
+
key = self._get_key(model_id, profile.tenant_id)
|
| 532 |
+
self._profiles[key] = profile
|
| 533 |
+
|
| 534 |
+
def get(
|
| 535 |
+
self,
|
| 536 |
+
model_id: str,
|
| 537 |
+
tenant_id: Optional[str] = None,
|
| 538 |
+
) -> Optional[ModelRiskProfile]:
|
| 539 |
+
"""Get a model's risk profile."""
|
| 540 |
+
key = self._get_key(model_id, tenant_id)
|
| 541 |
+
return self._profiles.get(key)
|
| 542 |
+
|
| 543 |
+
def update(
|
| 544 |
+
self,
|
| 545 |
+
model_id: str,
|
| 546 |
+
updates: Dict[str, Any],
|
| 547 |
+
tenant_id: Optional[str] = None,
|
| 548 |
+
) -> Optional[ModelRiskProfile]:
|
| 549 |
+
"""Update a model's risk profile."""
|
| 550 |
+
key = self._get_key(model_id, tenant_id)
|
| 551 |
+
if key in self._profiles:
|
| 552 |
+
profile = self._profiles[key]
|
| 553 |
+
for field, value in updates.items():
|
| 554 |
+
if hasattr(profile, field):
|
| 555 |
+
setattr(profile, field, value)
|
| 556 |
+
return profile
|
| 557 |
+
return None
|
| 558 |
+
|
| 559 |
+
def list_by_tier(
|
| 560 |
+
self,
|
| 561 |
+
risk_tier: RiskTier,
|
| 562 |
+
tenant_id: Optional[str] = None,
|
| 563 |
+
) -> List[ModelRiskProfile]:
|
| 564 |
+
"""List all models with a specific risk tier."""
|
| 565 |
+
results = []
|
| 566 |
+
for profile in self._profiles.values():
|
| 567 |
+
if profile.risk_tier == risk_tier:
|
| 568 |
+
if tenant_id is None or profile.tenant_id == tenant_id:
|
| 569 |
+
results.append(profile)
|
| 570 |
+
return results
|
| 571 |
+
|
| 572 |
+
def list_by_status(
|
| 573 |
+
self,
|
| 574 |
+
compliance_status: ComplianceStatus,
|
| 575 |
+
tenant_id: Optional[str] = None,
|
| 576 |
+
) -> List[ModelRiskProfile]:
|
| 577 |
+
"""List all models with a specific compliance status."""
|
| 578 |
+
results = []
|
| 579 |
+
for profile in self._profiles.values():
|
| 580 |
+
if profile.compliance_status == compliance_status:
|
| 581 |
+
if tenant_id is None or profile.tenant_id == tenant_id:
|
| 582 |
+
results.append(profile)
|
| 583 |
+
return results
|
| 584 |
+
|
| 585 |
+
def list_all(
|
| 586 |
+
self,
|
| 587 |
+
tenant_id: Optional[str] = None,
|
| 588 |
+
) -> List[ModelRiskProfile]:
|
| 589 |
+
"""List all registered model risk profiles."""
|
| 590 |
+
results = []
|
| 591 |
+
for profile in self._profiles.values():
|
| 592 |
+
if tenant_id is None or profile.tenant_id == tenant_id:
|
| 593 |
+
results.append(profile)
|
| 594 |
+
return results
|
| 595 |
+
|
| 596 |
+
def delete(
|
| 597 |
+
self,
|
| 598 |
+
model_id: str,
|
| 599 |
+
tenant_id: Optional[str] = None,
|
| 600 |
+
) -> bool:
|
| 601 |
+
"""Delete a model's risk profile."""
|
| 602 |
+
key = self._get_key(model_id, tenant_id)
|
| 603 |
+
if key in self._profiles:
|
| 604 |
+
del self._profiles[key]
|
| 605 |
+
return True
|
| 606 |
+
return False
|
| 607 |
+
|
| 608 |
+
def _get_key(
|
| 609 |
+
self,
|
| 610 |
+
model_id: str,
|
| 611 |
+
tenant_id: Optional[str] = None,
|
| 612 |
+
) -> str:
|
| 613 |
+
"""Generate storage key for model profile."""
|
| 614 |
+
if tenant_id:
|
| 615 |
+
return f"{tenant_id}:{model_id}"
|
| 616 |
+
return model_id
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
# Global risk registry instance
|
| 620 |
+
model_risk_registry = ModelRiskRegistry()
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def get_model_risk_registry() -> ModelRiskRegistry:
|
| 624 |
+
"""Get the global model risk registry instance."""
|
| 625 |
+
return model_risk_registry
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
__all__ = [
|
| 629 |
+
"RiskTier",
|
| 630 |
+
"ComplianceStatus",
|
| 631 |
+
"ModelRiskProfile",
|
| 632 |
+
"ModelRiskRegistry",
|
| 633 |
+
"model_risk_registry",
|
| 634 |
+
"get_model_risk_registry",
|
| 635 |
+
"ModelResponse",
|
| 636 |
+
"GenerationConfig",
|
| 637 |
+
"BaseModelExecutor",
|
| 638 |
+
"TransformersExecutor",
|
| 639 |
+
"ModelRegistry",
|
| 640 |
+
"model_registry",
|
| 641 |
+
"get_model_executor",
|
| 642 |
+
]
|
backend/core/or edit again
ADDED
|
File without changes
|
backend/core/orchestrator.py
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation Orchestrator - Production Grade
|
| 3 |
+
|
| 4 |
+
Manages the full lifecycle of evaluation runs, coordinating between:
|
| 5 |
+
- Attacker agent
|
| 6 |
+
- Mutation engine
|
| 7 |
+
- Model executor
|
| 8 |
+
- Defender agent
|
| 9 |
+
- Judge agent
|
| 10 |
+
- Scoring aggregator
|
| 11 |
+
- Database persistence
|
| 12 |
+
|
| 13 |
+
This is a production-grade async pipeline with:
|
| 14 |
+
- Concurrency control
|
| 15 |
+
- Deterministic configuration
|
| 16 |
+
- Retry logic
|
| 17 |
+
- Observability
|
| 18 |
+
- Performance tracking
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import hashlib
|
| 23 |
+
import json
|
| 24 |
+
import time
|
| 25 |
+
import uuid
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
from enum import Enum
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, Dict, List, Optional
|
| 30 |
+
from uuid import UUID
|
| 31 |
+
|
| 32 |
+
from pydantic import BaseModel, Field
|
| 33 |
+
from sqlalchemy import select
|
| 34 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 35 |
+
|
| 36 |
+
from agents.attacker import AttackEngine, AttackRequest, get_attack_engine
|
| 37 |
+
from agents.defender import DefenderEngine, DefenderRequest, get_defender_engine
|
| 38 |
+
from agents.judge import JudgeEngine, JudgeRequest, get_judge_engine
|
| 39 |
+
from agents.mutation import MutationRequest, get_mutation_engine
|
| 40 |
+
from backend.core.config import settings
|
| 41 |
+
from backend.core.dataset_loader import (
|
| 42 |
+
DatasetLoader,
|
| 43 |
+
EvaluationDataset,
|
| 44 |
+
SamplingConfig,
|
| 45 |
+
get_dataset_loader,
|
| 46 |
+
)
|
| 47 |
+
from backend.core.dataset_schemas import DatasetMetadata
|
| 48 |
+
from backend.core.exceptions import EvaluationError, EvaluationTimeoutError
|
| 49 |
+
from backend.db.models import EvaluationResult, EvaluationRun
|
| 50 |
+
from backend.db.session import get_db_context
|
| 51 |
+
from backend.logging.logger import get_logger
|
| 52 |
+
from backend.scoring.aggregator import ScoreAggregator, get_aggregator
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# =============================================================================
|
| 56 |
+
# Enums
|
| 57 |
+
# =============================================================================
|
| 58 |
+
|
| 59 |
+
class RunStatus(str, Enum):
|
| 60 |
+
"""Status of an evaluation run."""
|
| 61 |
+
PENDING = "pending"
|
| 62 |
+
RUNNING = "running"
|
| 63 |
+
COMPLETED = "completed"
|
| 64 |
+
FAILED = "failed"
|
| 65 |
+
CANCELLED = "cancelled"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class LogEvent(str, Enum):
|
| 69 |
+
"""Observability log events."""
|
| 70 |
+
RUN_STARTED = "RUN_STARTED"
|
| 71 |
+
RUN_COMPLETED = "RUN_COMPLETED"
|
| 72 |
+
RUN_FAILED = "RUN_FAILED"
|
| 73 |
+
SAMPLE_STARTED = "SAMPLE_STARTED"
|
| 74 |
+
SAMPLE_COMPLETED = "SAMPLE_COMPLETED"
|
| 75 |
+
SAMPLE_FAILED = "SAMPLE_FAILED"
|
| 76 |
+
ATTACK_COMPLETED = "ATTACK_COMPLETED"
|
| 77 |
+
MUTATION_COMPLETED = "MUTATION_COMPLETED"
|
| 78 |
+
MODEL_COMPLETED = "MODEL_COMPLETED"
|
| 79 |
+
DEFENDER_COMPLETED = "DEFENDER_COMPLETED"
|
| 80 |
+
JUDGE_COMPLETED = "JUDGE_COMPLETED"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# =============================================================================
|
| 84 |
+
# Run Configuration
|
| 85 |
+
# =============================================================================
|
| 86 |
+
|
| 87 |
+
class RunConfig(BaseModel):
|
| 88 |
+
"""
|
| 89 |
+
Deterministic run configuration.
|
| 90 |
+
|
| 91 |
+
All fields are used to generate a config hash for reproducibility.
|
| 92 |
+
"""
|
| 93 |
+
run_id: UUID = Field(description="Unique run identifier")
|
| 94 |
+
model_name: str = Field(description="Name of the model to evaluate")
|
| 95 |
+
model_version: str = Field(default="latest", description="Model version")
|
| 96 |
+
dataset_name: str = Field(description="Dataset name")
|
| 97 |
+
dataset_version: str = Field(description="Dataset version")
|
| 98 |
+
weights: Dict[str, float] = Field(
|
| 99 |
+
default_factory=lambda: {
|
| 100 |
+
"hallucination": settings.hallucination_weight,
|
| 101 |
+
"toxicity": settings.toxicity_weight,
|
| 102 |
+
"bias": settings.bias_weight,
|
| 103 |
+
"confidence": settings.confidence_weight,
|
| 104 |
+
},
|
| 105 |
+
description="Scoring weights"
|
| 106 |
+
)
|
| 107 |
+
mutation_depth: int = Field(default=2, ge=0, le=10, description="Mutation depth")
|
| 108 |
+
attack_types: List[str] = Field(
|
| 109 |
+
default_factory=list,
|
| 110 |
+
description="List of attack types to use"
|
| 111 |
+
)
|
| 112 |
+
max_concurrency: int = Field(
|
| 113 |
+
default=4,
|
| 114 |
+
ge=1,
|
| 115 |
+
le=32,
|
| 116 |
+
description="Maximum concurrent samples"
|
| 117 |
+
)
|
| 118 |
+
max_retries: int = Field(
|
| 119 |
+
default=2,
|
| 120 |
+
ge=0,
|
| 121 |
+
le=5,
|
| 122 |
+
description="Maximum retries for model inference"
|
| 123 |
+
)
|
| 124 |
+
sampling_config: Optional[Dict[str, Any]] = Field(
|
| 125 |
+
default=None,
|
| 126 |
+
description="Dataset sampling configuration"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
def get_config_hash(self) -> str:
|
| 130 |
+
"""Generate SHA256 hash of configuration for reproducibility."""
|
| 131 |
+
config_dict = self.model_dump()
|
| 132 |
+
# Sort keys for deterministic hashing
|
| 133 |
+
sorted_config = json.dumps(config_dict, sort_keys=True)
|
| 134 |
+
return hashlib.sha256(sorted_config.encode()).hexdigest()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# =============================================================================
|
| 138 |
+
# Data Models
|
| 139 |
+
# =============================================================================
|
| 140 |
+
|
| 141 |
+
class EvaluationInput(BaseModel):
|
| 142 |
+
"""Input for starting an evaluation run."""
|
| 143 |
+
|
| 144 |
+
model_name: str = Field(description="Name of the model to evaluate")
|
| 145 |
+
model_version: str = Field(default="latest", description="Model version")
|
| 146 |
+
dataset_name: str = Field(description="Dataset name")
|
| 147 |
+
dataset_version: str = Field(description="Dataset version to use")
|
| 148 |
+
weights: Optional[Dict[str, float]] = Field(
|
| 149 |
+
default=None,
|
| 150 |
+
description="Optional scoring weights override"
|
| 151 |
+
)
|
| 152 |
+
mutation_depth: int = Field(default=2, ge=0, le=10, description="Mutation depth")
|
| 153 |
+
attack_types: Optional[List[str]] = Field(
|
| 154 |
+
default=None,
|
| 155 |
+
description="List of attack types"
|
| 156 |
+
)
|
| 157 |
+
max_concurrency: int = Field(
|
| 158 |
+
default=4,
|
| 159 |
+
ge=1,
|
| 160 |
+
le=32,
|
| 161 |
+
description="Maximum concurrent samples"
|
| 162 |
+
)
|
| 163 |
+
sampling_config: Optional[Dict[str, Any]] = Field(
|
| 164 |
+
default=None,
|
| 165 |
+
description="Optional sampling configuration"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class EvaluationOutput(BaseModel):
|
| 170 |
+
"""Output of an evaluation run."""
|
| 171 |
+
|
| 172 |
+
run_id: str = Field(description="Unique identifier for this run")
|
| 173 |
+
model_name: str = Field(description="Name of the evaluated model")
|
| 174 |
+
model_version: str = Field(description="Version of the evaluated model")
|
| 175 |
+
dataset_version: str = Field(description="Dataset version used")
|
| 176 |
+
status: RunStatus = Field(description="Final status of the run")
|
| 177 |
+
composite_score: Optional[float] = Field(
|
| 178 |
+
default=None,
|
| 179 |
+
description="Composite robustness score (0-1)",
|
| 180 |
+
ge=0.0,
|
| 181 |
+
le=1.0
|
| 182 |
+
)
|
| 183 |
+
metrics: Dict[str, float] = Field(
|
| 184 |
+
default_factory=dict,
|
| 185 |
+
description="Individual metric scores"
|
| 186 |
+
)
|
| 187 |
+
performance: Dict[str, float] = Field(
|
| 188 |
+
default_factory=dict,
|
| 189 |
+
description="Performance metrics"
|
| 190 |
+
)
|
| 191 |
+
started_at: datetime = Field(description="When the run started")
|
| 192 |
+
completed_at: Optional[datetime] = Field(
|
| 193 |
+
default=None,
|
| 194 |
+
description="When the run completed"
|
| 195 |
+
)
|
| 196 |
+
error: Optional[str] = Field(
|
| 197 |
+
default=None,
|
| 198 |
+
description="Error message if failed"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class SampleResult(BaseModel):
|
| 203 |
+
"""Result for a single sample."""
|
| 204 |
+
sample_id: str
|
| 205 |
+
attack_type: Optional[str] = None
|
| 206 |
+
mutation_type: Optional[str] = None
|
| 207 |
+
hallucination: Optional[float] = None
|
| 208 |
+
toxicity: Optional[float] = None
|
| 209 |
+
bias: Optional[float] = None
|
| 210 |
+
confidence: Optional[float] = None
|
| 211 |
+
robustness: Optional[float] = None
|
| 212 |
+
raw_output: Optional[str] = None
|
| 213 |
+
processed_prompt: Optional[str] = None
|
| 214 |
+
latency_ms: Optional[float] = None
|
| 215 |
+
success: bool = True
|
| 216 |
+
error: Optional[str] = None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# =============================================================================
|
| 220 |
+
# Orchestrator Class
|
| 221 |
+
# =============================================================================
|
| 222 |
+
|
| 223 |
+
class EvaluationOrchestrator:
|
| 224 |
+
"""
|
| 225 |
+
Production-grade orchestrator for evaluation pipeline.
|
| 226 |
+
|
| 227 |
+
Coordinates attacker → mutation → model → defender → judge → scoring
|
| 228 |
+
with full observability, concurrency control, and persistence.
|
| 229 |
+
"""
|
| 230 |
+
|
| 231 |
+
def __init__(self):
|
| 232 |
+
self.logger = get_logger(__name__)
|
| 233 |
+
self._active_runs: Dict[str, asyncio.Task] = {}
|
| 234 |
+
self._aggregator = get_aggregator()
|
| 235 |
+
|
| 236 |
+
def _create_run_config(self, evaluation_input: EvaluationInput, run_id: UUID) -> RunConfig:
|
| 237 |
+
"""Create RunConfig from evaluation input."""
|
| 238 |
+
return RunConfig(
|
| 239 |
+
run_id=run_id,
|
| 240 |
+
model_name=evaluation_input.model_name,
|
| 241 |
+
model_version=evaluation_input.model_version,
|
| 242 |
+
dataset_name=evaluation_input.dataset_name,
|
| 243 |
+
dataset_version=evaluation_input.dataset_version,
|
| 244 |
+
weights=evaluation_input.weights or {
|
| 245 |
+
"hallucination": settings.hallucination_weight,
|
| 246 |
+
"toxicity": settings.toxicity_weight,
|
| 247 |
+
"bias": settings.bias_weight,
|
| 248 |
+
"confidence": settings.confidence_weight,
|
| 249 |
+
},
|
| 250 |
+
mutation_depth=evaluation_input.mutation_depth,
|
| 251 |
+
attack_types=evaluation_input.attack_types or ["jailbreak"],
|
| 252 |
+
max_concurrency=evaluation_input.max_concurrency,
|
| 253 |
+
sampling_config=evaluation_input.sampling_config,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
async def _persist_run_start(self, config: RunConfig) -> None:
|
| 257 |
+
"""Persist run start to database."""
|
| 258 |
+
try:
|
| 259 |
+
async with get_db_context() as session:
|
| 260 |
+
run = EvaluationRun(
|
| 261 |
+
id=config.run_id,
|
| 262 |
+
model_name=config.model_name,
|
| 263 |
+
model_version=config.model_version,
|
| 264 |
+
dataset_version=config.dataset_version,
|
| 265 |
+
status=RunStatus.PENDING.value,
|
| 266 |
+
config_hash=config.get_config_hash(),
|
| 267 |
+
)
|
| 268 |
+
session.add(run)
|
| 269 |
+
await session.commit()
|
| 270 |
+
self.logger.info("Run created in database", run_id=str(config.run_id))
|
| 271 |
+
except Exception as e:
|
| 272 |
+
self.logger.warning("Failed to persist run start", error=str(e))
|
| 273 |
+
|
| 274 |
+
async def _persist_sample_result(self, result: SampleResult, run_id: UUID) -> None:
|
| 275 |
+
"""Persist individual sample result to database."""
|
| 276 |
+
try:
|
| 277 |
+
async with get_db_context() as session:
|
| 278 |
+
eval_result = EvaluationResult(
|
| 279 |
+
run_id=run_id,
|
| 280 |
+
sample_id=result.sample_id,
|
| 281 |
+
attack_type=result.attack_type,
|
| 282 |
+
mutation_type=result.mutation_type,
|
| 283 |
+
hallucination=result.hallucination,
|
| 284 |
+
toxicity=result.toxicity,
|
| 285 |
+
bias=result.bias,
|
| 286 |
+
confidence=result.confidence,
|
| 287 |
+
robustness=result.robustness,
|
| 288 |
+
raw_output=result.raw_output,
|
| 289 |
+
processed_prompt=result.processed_prompt,
|
| 290 |
+
processing_time_ms=result.latency_ms,
|
| 291 |
+
)
|
| 292 |
+
session.add(eval_result)
|
| 293 |
+
await session.commit()
|
| 294 |
+
except Exception as e:
|
| 295 |
+
self.logger.warning(
|
| 296 |
+
"Failed to persist sample result",
|
| 297 |
+
sample_id=result.sample_id,
|
| 298 |
+
error=str(e)
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
async def _update_run_status(
|
| 302 |
+
self,
|
| 303 |
+
run_id: UUID,
|
| 304 |
+
status: RunStatus,
|
| 305 |
+
composite_score: Optional[float] = None,
|
| 306 |
+
error: Optional[str] = None
|
| 307 |
+
) -> None:
|
| 308 |
+
"""Update run status in database."""
|
| 309 |
+
try:
|
| 310 |
+
async with get_db_context() as session:
|
| 311 |
+
stmt = select(EvaluationRun).where(EvaluationRun.id == run_id)
|
| 312 |
+
result = await session.execute(stmt)
|
| 313 |
+
run = result.scalar_one_or_none()
|
| 314 |
+
|
| 315 |
+
if run:
|
| 316 |
+
run.status = status.value
|
| 317 |
+
if composite_score is not None:
|
| 318 |
+
run.composite_score = composite_score
|
| 319 |
+
if error:
|
| 320 |
+
run.status = RunStatus.FAILED.value
|
| 321 |
+
await session.commit()
|
| 322 |
+
except Exception as e:
|
| 323 |
+
self.logger.warning("Failed to update run status", error=str(e))
|
| 324 |
+
|
| 325 |
+
def _log_event(
|
| 326 |
+
self,
|
| 327 |
+
event: LogEvent,
|
| 328 |
+
run_id: str,
|
| 329 |
+
sample_id: Optional[str] = None,
|
| 330 |
+
**kwargs: Any
|
| 331 |
+
) -> None:
|
| 332 |
+
"""Log observability event."""
|
| 333 |
+
log_data = {
|
| 334 |
+
"event": event.value,
|
| 335 |
+
"run_id": run_id,
|
| 336 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 337 |
+
}
|
| 338 |
+
if sample_id:
|
| 339 |
+
log_data["sample_id"] = sample_id
|
| 340 |
+
log_data.update(kwargs)
|
| 341 |
+
|
| 342 |
+
if event in [LogEvent.RUN_STARTED, LogEvent.SAMPLE_COMPLETED]:
|
| 343 |
+
self.logger.info(**log_data)
|
| 344 |
+
elif event in [LogEvent.RUN_FAILED, LogEvent.SAMPLE_FAILED]:
|
| 345 |
+
self.logger.error(**log_data)
|
| 346 |
+
else:
|
| 347 |
+
self.logger.debug(**log_data)
|
| 348 |
+
|
| 349 |
+
def _get_dataset_prompts(
|
| 350 |
+
self,
|
| 351 |
+
dataset_name: str,
|
| 352 |
+
dataset_version: Optional[str] = None,
|
| 353 |
+
sampling_config: Optional[SamplingConfig] = None,
|
| 354 |
+
run_id: Optional[str] = None,
|
| 355 |
+
) -> tuple[List[Dict[str, Any]], Optional[DatasetMetadata]]:
|
| 356 |
+
"""
|
| 357 |
+
Get sample prompts from the dataset for evaluation.
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
Tuple of (list of samples with prompt/id, dataset metadata)
|
| 361 |
+
"""
|
| 362 |
+
try:
|
| 363 |
+
loader = get_dataset_loader()
|
| 364 |
+
|
| 365 |
+
data, metadata = loader.load_processed_dataset(
|
| 366 |
+
name=dataset_name,
|
| 367 |
+
version=dataset_version,
|
| 368 |
+
verify_checksum=True,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
# Apply sampling if configured
|
| 372 |
+
if sampling_config and run_id:
|
| 373 |
+
data, sampling_info = loader.sample_dataset(
|
| 374 |
+
data=data,
|
| 375 |
+
config=sampling_config,
|
| 376 |
+
run_id=run_id,
|
| 377 |
+
dataset_version=dataset_version or metadata.version,
|
| 378 |
+
)
|
| 379 |
+
self.logger.info(
|
| 380 |
+
"Dataset sampled",
|
| 381 |
+
dataset_name=dataset_name,
|
| 382 |
+
dataset_version=dataset_version or metadata.version,
|
| 383 |
+
sampling_method=sampling_config.method,
|
| 384 |
+
sample_size=len(data),
|
| 385 |
+
seed=sampling_info.get("seed"),
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
self.logger.info(
|
| 389 |
+
"Loaded dataset prompts",
|
| 390 |
+
dataset_name=dataset_name,
|
| 391 |
+
dataset_version=dataset_version or metadata.version,
|
| 392 |
+
prompt_count=len(data),
|
| 393 |
+
checksum=metadata.checksum,
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
return data, metadata
|
| 397 |
+
|
| 398 |
+
except FileNotFoundError as e:
|
| 399 |
+
self.logger.warning(
|
| 400 |
+
"Dataset not found, using fallback prompts",
|
| 401 |
+
dataset_name=dataset_name,
|
| 402 |
+
error=str(e),
|
| 403 |
+
)
|
| 404 |
+
# Fallback to sample prompts
|
| 405 |
+
sample_prompts = [
|
| 406 |
+
{"prompt": "Explain how to cook a meal", "sample_id": "sample_0"},
|
| 407 |
+
{"prompt": "What are the benefits of exercise?", "sample_id": "sample_1"},
|
| 408 |
+
{"prompt": "Tell me about the history of computers", "sample_id": "sample_2"},
|
| 409 |
+
{"prompt": "How do plants grow?", "sample_id": "sample_3"},
|
| 410 |
+
{"prompt": "What is the capital of France?", "sample_id": "sample_4"},
|
| 411 |
+
]
|
| 412 |
+
return sample_prompts, None
|
| 413 |
+
|
| 414 |
+
async def start_run(
|
| 415 |
+
self,
|
| 416 |
+
evaluation_input: EvaluationInput,
|
| 417 |
+
) -> EvaluationOutput:
|
| 418 |
+
"""
|
| 419 |
+
Start a new evaluation run.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
evaluation_input: Configuration for the evaluation
|
| 423 |
+
|
| 424 |
+
Returns:
|
| 425 |
+
EvaluationOutput with initial run information
|
| 426 |
+
"""
|
| 427 |
+
run_id = uuid.uuid4()
|
| 428 |
+
run_id_str = str(run_id)
|
| 429 |
+
|
| 430 |
+
self.logger.info(
|
| 431 |
+
"Starting evaluation run",
|
| 432 |
+
run_id=run_id_str,
|
| 433 |
+
model=evaluation_input.model_name,
|
| 434 |
+
dataset=evaluation_input.dataset_name,
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
# Create run config
|
| 438 |
+
config = self._create_run_config(evaluation_input, run_id)
|
| 439 |
+
|
| 440 |
+
# Create initial output
|
| 441 |
+
output = EvaluationOutput(
|
| 442 |
+
run_id=run_id_str,
|
| 443 |
+
model_name=evaluation_input.model_name,
|
| 444 |
+
model_version=evaluation_input.model_version,
|
| 445 |
+
dataset_version=evaluation_input.dataset_version,
|
| 446 |
+
status=RunStatus.PENDING,
|
| 447 |
+
started_at=datetime.utcnow(),
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
# Persist run start to database
|
| 451 |
+
await self._persist_run_start(config)
|
| 452 |
+
|
| 453 |
+
# Log observability event
|
| 454 |
+
self._log_event(LogEvent.RUN_STARTED, run_id_str, config=config.model_dump())
|
| 455 |
+
|
| 456 |
+
# Start async execution
|
| 457 |
+
task = asyncio.create_task(
|
| 458 |
+
self._execute_run(evaluation_input, output, config)
|
| 459 |
+
)
|
| 460 |
+
self._active_runs[run_id_str] = task
|
| 461 |
+
|
| 462 |
+
return output
|
| 463 |
+
|
| 464 |
+
async def _execute_run(
|
| 465 |
+
self,
|
| 466 |
+
evaluation_input: EvaluationInput,
|
| 467 |
+
output: EvaluationOutput,
|
| 468 |
+
config: RunConfig,
|
| 469 |
+
) -> EvaluationOutput:
|
| 470 |
+
"""
|
| 471 |
+
Execute the evaluation run asynchronously with full pipeline.
|
| 472 |
+
"""
|
| 473 |
+
run_id = config.run_id
|
| 474 |
+
run_id_str = str(run_id)
|
| 475 |
+
output.status = RunStatus.RUNNING
|
| 476 |
+
|
| 477 |
+
# Update status in DB
|
| 478 |
+
await self._update_run_status(run_id, RunStatus.RUNNING)
|
| 479 |
+
|
| 480 |
+
# Create semaphore for concurrency control
|
| 481 |
+
semaphore = asyncio.Semaphore(config.max_concurrency)
|
| 482 |
+
|
| 483 |
+
# Track performance metrics
|
| 484 |
+
start_time = time.time()
|
| 485 |
+
sample_results: List[SampleResult] = []
|
| 486 |
+
failed_count = 0
|
| 487 |
+
|
| 488 |
+
try:
|
| 489 |
+
# =========================================================================
|
| 490 |
+
# Load datasets
|
| 491 |
+
# =========================================================================
|
| 492 |
+
self.logger.info("Loading dataset", run_id=run_id_str)
|
| 493 |
+
|
| 494 |
+
sampling_config = None
|
| 495 |
+
if evaluation_input.sampling_config:
|
| 496 |
+
sampling_config = SamplingConfig(**evaluation_input.sampling_config)
|
| 497 |
+
|
| 498 |
+
samples, metadata = self._get_dataset_prompts(
|
| 499 |
+
dataset_name=evaluation_input.dataset_name,
|
| 500 |
+
dataset_version=evaluation_input.dataset_version,
|
| 501 |
+
sampling_config=sampling_config,
|
| 502 |
+
run_id=run_id_str,
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# Get agent engines
|
| 506 |
+
attacker_engine = get_attack_engine()
|
| 507 |
+
mutation_engine = get_mutation_engine()
|
| 508 |
+
defender_engine = get_defender_engine()
|
| 509 |
+
judge_engine = get_judge_engine()
|
| 510 |
+
|
| 511 |
+
# =========================================================================
|
| 512 |
+
# Process samples with bounded concurrency
|
| 513 |
+
# =========================================================================
|
| 514 |
+
async def process_sample(sample: Dict[str, Any], index: int) -> SampleResult:
|
| 515 |
+
"""Process a single sample through the full pipeline."""
|
| 516 |
+
sample_id = sample.get("sample_id", f"sample_{index}")
|
| 517 |
+
base_prompt = sample.get("prompt", sample.get("base_prompt", ""))
|
| 518 |
+
|
| 519 |
+
self._log_event(
|
| 520 |
+
LogEvent.SAMPLE_STARTED,
|
| 521 |
+
run_id_str,
|
| 522 |
+
sample_id=sample_id,
|
| 523 |
+
prompt_length=len(base_prompt)
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
sample_start_time = time.time()
|
| 527 |
+
|
| 528 |
+
try:
|
| 529 |
+
# Use semaphore for concurrency control
|
| 530 |
+
async with semaphore:
|
| 531 |
+
# =================================================================
|
| 532 |
+
# Step 1: Attack Generation
|
| 533 |
+
# =================================================================
|
| 534 |
+
attack_request = AttackRequest(
|
| 535 |
+
run_id=run_id,
|
| 536 |
+
sample_id=sample_id,
|
| 537 |
+
base_prompt=base_prompt,
|
| 538 |
+
attack_type=config.attack_types[0] if config.attack_types else "jailbreak",
|
| 539 |
+
temperature=0.7,
|
| 540 |
+
chain_depth=2,
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
attack_response = await attacker_engine.execute(attack_request)
|
| 544 |
+
|
| 545 |
+
self._log_event(
|
| 546 |
+
LogEvent.ATTACK_COMPLETED,
|
| 547 |
+
run_id_str,
|
| 548 |
+
sample_id=sample_id,
|
| 549 |
+
attack_type=attack_response.attack_type,
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
# =================================================================
|
| 553 |
+
# Step 2: Mutation
|
| 554 |
+
# =================================================================
|
| 555 |
+
mutation_request = MutationRequest(
|
| 556 |
+
run_id=run_id,
|
| 557 |
+
sample_id=sample_id,
|
| 558 |
+
base_prompt=attack_response.mutated_prompt,
|
| 559 |
+
attack_type=attack_response.attack_type,
|
| 560 |
+
mutation_depth=config.mutation_depth,
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
mutation_response = await mutation_engine.mutate(mutation_request)
|
| 564 |
+
|
| 565 |
+
self._log_event(
|
| 566 |
+
LogEvent.MUTATION_COMPLETED,
|
| 567 |
+
run_id_str,
|
| 568 |
+
sample_id=sample_id,
|
| 569 |
+
mutation_depth=mutation_response.mutation_depth,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
# =================================================================
|
| 573 |
+
# Step 3: Model Execution (with retry)
|
| 574 |
+
# =================================================================
|
| 575 |
+
model_output = None
|
| 576 |
+
for retry in range(config.max_retries + 1):
|
| 577 |
+
try:
|
| 578 |
+
# TODO: Replace with actual model executor
|
| 579 |
+
# Placeholder: In production, this would call the actual model
|
| 580 |
+
model_output = await self._execute_model(
|
| 581 |
+
mutation_response.mutated_prompt,
|
| 582 |
+
evaluation_input.model_name,
|
| 583 |
+
)
|
| 584 |
+
break
|
| 585 |
+
except Exception as e:
|
| 586 |
+
if retry < config.max_retries:
|
| 587 |
+
self.logger.warning(
|
| 588 |
+
"Model inference failed, retrying",
|
| 589 |
+
sample_id=sample_id,
|
| 590 |
+
retry=retry + 1,
|
| 591 |
+
error=str(e)
|
| 592 |
+
)
|
| 593 |
+
await asyncio.sleep(0.5 * (retry + 1))
|
| 594 |
+
else:
|
| 595 |
+
raise
|
| 596 |
+
|
| 597 |
+
self._log_event(
|
| 598 |
+
LogEvent.MODEL_COMPLETED,
|
| 599 |
+
run_id_str,
|
| 600 |
+
sample_id=sample_id,
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
# =================================================================
|
| 604 |
+
# Step 4: Defender Evaluation
|
| 605 |
+
# =================================================================
|
| 606 |
+
defender_request = DefenderRequest(
|
| 607 |
+
run_id=run_id,
|
| 608 |
+
sample_id=sample_id,
|
| 609 |
+
model_output=model_output,
|
| 610 |
+
attack_type=attack_response.attack_type,
|
| 611 |
+
base_prompt=mutation_response.mutated_prompt,
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
defense_output = await defender_engine.evaluate(defender_request)
|
| 615 |
+
|
| 616 |
+
self._log_event(
|
| 617 |
+
LogEvent.DEFENDER_COMPLETED,
|
| 618 |
+
run_id_str,
|
| 619 |
+
sample_id=sample_id,
|
| 620 |
+
risk_score=defense_output.risk_score,
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
# =================================================================
|
| 624 |
+
# Step 5: Judge Evaluation
|
| 625 |
+
# =================================================================
|
| 626 |
+
judge_request = JudgeRequest(
|
| 627 |
+
run_id=run_id,
|
| 628 |
+
sample_id=sample_id,
|
| 629 |
+
prompt=mutation_response.mutated_prompt,
|
| 630 |
+
model_output=model_output,
|
| 631 |
+
defender_risk_score=defense_output.risk_score,
|
| 632 |
+
defender_toxicity_score=defense_output.toxicity_score,
|
| 633 |
+
token_probs=None,
|
| 634 |
+
temperature=0.7,
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
judge_output = await judge_engine.evaluate(judge_request)
|
| 638 |
+
|
| 639 |
+
self._log_event(
|
| 640 |
+
LogEvent.JUDGE_COMPLETED,
|
| 641 |
+
run_id_str,
|
| 642 |
+
sample_id=sample_id,
|
| 643 |
+
hallucination=judge_output.hallucination_score,
|
| 644 |
+
bias=judge_output.bias_score,
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
# =================================================================
|
| 648 |
+
# Calculate robustness for this sample
|
| 649 |
+
# =================================================================
|
| 650 |
+
robustness = self._aggregator.calculate_composite(
|
| 651 |
+
hallucination=judge_output.hallucination_score,
|
| 652 |
+
toxicity=defense_output.toxicity_score,
|
| 653 |
+
bias=judge_output.bias_score,
|
| 654 |
+
confidence=judge_output.confidence_score,
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
latency_ms = (time.time() - sample_start_time) * 1000
|
| 658 |
+
|
| 659 |
+
# Build result
|
| 660 |
+
result = SampleResult(
|
| 661 |
+
sample_id=sample_id,
|
| 662 |
+
attack_type=attack_response.attack_type,
|
| 663 |
+
mutation_type="-".join(mutation_response.mutation_trace) if mutation_response.mutation_trace else None,
|
| 664 |
+
hallucination=judge_output.hallucination_score,
|
| 665 |
+
toxicity=defense_output.toxicity_score,
|
| 666 |
+
bias=judge_output.bias_score,
|
| 667 |
+
confidence=judge_output.confidence_score,
|
| 668 |
+
robustness=robustness,
|
| 669 |
+
raw_output=model_output,
|
| 670 |
+
processed_prompt=mutation_response.mutated_prompt,
|
| 671 |
+
latency_ms=latency_ms,
|
| 672 |
+
success=True,
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
self._log_event(
|
| 676 |
+
LogEvent.SAMPLE_COMPLETED,
|
| 677 |
+
run_id_str,
|
| 678 |
+
sample_id=sample_id,
|
| 679 |
+
latency_ms=latency_ms,
|
| 680 |
+
robustness=robustness,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
return result
|
| 684 |
+
|
| 685 |
+
except Exception as e:
|
| 686 |
+
latency_ms = (time.time() - sample_start_time) * 1000
|
| 687 |
+
self.logger.error(
|
| 688 |
+
"Sample processing failed",
|
| 689 |
+
run_id=run_id_str,
|
| 690 |
+
sample_id=sample_id,
|
| 691 |
+
error=str(e)
|
| 692 |
+
)
|
| 693 |
+
|
| 694 |
+
self._log_event(
|
| 695 |
+
LogEvent.SAMPLE_FAILED,
|
| 696 |
+
run_id_str,
|
| 697 |
+
sample_id=sample_id,
|
| 698 |
+
error=str(e),
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
return SampleResult(
|
| 702 |
+
sample_id=sample_id,
|
| 703 |
+
latency_ms=latency_ms,
|
| 704 |
+
success=False,
|
| 705 |
+
error=str(e),
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
# Process all samples
|
| 709 |
+
tasks = [process_sample(sample, idx) for idx, sample in enumerate(samples)]
|
| 710 |
+
sample_results = await asyncio.gather(*tasks)
|
| 711 |
+
|
| 712 |
+
# Count failures
|
| 713 |
+
failed_count = sum(1 for r in sample_results if not r.success)
|
| 714 |
+
|
| 715 |
+
# =========================================================================
|
| 716 |
+
# Aggregate Metrics
|
| 717 |
+
# =========================================================================
|
| 718 |
+
successful_results = [r for r in sample_results if r.success]
|
| 719 |
+
|
| 720 |
+
if successful_results:
|
| 721 |
+
# Calculate means
|
| 722 |
+
n = len(successful_results)
|
| 723 |
+
|
| 724 |
+
mean_hallucination = sum(r.hallucination or 0 for r in successful_results) / n
|
| 725 |
+
mean_toxicity = sum(r.toxicity or 0 for r in successful_results) / n
|
| 726 |
+
mean_bias = sum(r.bias or 0 for r in successful_results) / n
|
| 727 |
+
mean_confidence = sum(r.confidence or 0 for r in successful_results) / n
|
| 728 |
+
mean_robustness = sum(r.robustness or 0 for r in successful_results) / n
|
| 729 |
+
mean_latency = sum(r.latency_ms or 0 for r in successful_results) / n
|
| 730 |
+
|
| 731 |
+
# Calculate composite score
|
| 732 |
+
composite_score = self._aggregator.calculate_composite(
|
| 733 |
+
hallucination=mean_hallucination,
|
| 734 |
+
toxicity=mean_toxicity,
|
| 735 |
+
bias=mean_bias,
|
| 736 |
+
confidence=mean_confidence,
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
# Update output
|
| 740 |
+
output.composite_score = composite_score
|
| 741 |
+
output.metrics = {
|
| 742 |
+
"hallucination": mean_hallucination,
|
| 743 |
+
"toxicity": mean_toxicity,
|
| 744 |
+
"bias": mean_bias,
|
| 745 |
+
"confidence": mean_confidence,
|
| 746 |
+
"robustness": mean_robustness,
|
| 747 |
+
"total_samples": len(sample_results),
|
| 748 |
+
"successful_samples": len(successful_results),
|
| 749 |
+
"failed_samples": failed_count,
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
# Performance metrics
|
| 753 |
+
total_time = time.time() - start_time
|
| 754 |
+
throughput = len(sample_results) / total_time if total_time > 0 else 0
|
| 755 |
+
failure_rate = failed_count / len(sample_results) if sample_results else 0
|
| 756 |
+
|
| 757 |
+
output.performance = {
|
| 758 |
+
"mean_latency_ms": mean_latency,
|
| 759 |
+
"total_time_seconds": total_time,
|
| 760 |
+
"throughput_samples_per_second": throughput,
|
| 761 |
+
"failure_rate": failure_rate,
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
# Persist each sample result
|
| 765 |
+
for result in sample_results:
|
| 766 |
+
await self._persist_sample_result(result, run_id)
|
| 767 |
+
|
| 768 |
+
# Update run status in DB
|
| 769 |
+
await self._update_run_status(run_id, RunStatus.COMPLETED, composite_score)
|
| 770 |
+
|
| 771 |
+
else:
|
| 772 |
+
# All samples failed
|
| 773 |
+
output.error = "All samples failed processing"
|
| 774 |
+
await self._update_run_status(run_id, RunStatus.FAILED, error=output.error)
|
| 775 |
+
|
| 776 |
+
output.status = RunStatus.COMPLETED
|
| 777 |
+
output.completed_at = datetime.utcnow()
|
| 778 |
+
|
| 779 |
+
# Save artifacts
|
| 780 |
+
await self._save_artifacts(output, config, metadata)
|
| 781 |
+
|
| 782 |
+
# Log observability event
|
| 783 |
+
self._log_event(
|
| 784 |
+
LogEvent.RUN_COMPLETED,
|
| 785 |
+
run_id_str,
|
| 786 |
+
composite_score=output.composite_score,
|
| 787 |
+
total_samples=len(sample_results),
|
| 788 |
+
failed_samples=failed_count,
|
| 789 |
+
)
|
| 790 |
+
|
| 791 |
+
self.logger.info(
|
| 792 |
+
"Evaluation run completed",
|
| 793 |
+
run_id=run_id_str,
|
| 794 |
+
composite_score=output.composite_score,
|
| 795 |
+
total_samples=len(sample_results),
|
| 796 |
+
failed_samples=failed_count,
|
| 797 |
+
)
|
| 798 |
+
|
| 799 |
+
except Exception as e:
|
| 800 |
+
output.status = RunStatus.FAILED
|
| 801 |
+
output.error = str(e)
|
| 802 |
+
output.completed_at = datetime.utcnow()
|
| 803 |
+
|
| 804 |
+
await self._update_run_status(run_id, RunStatus.FAILED, error=str(e))
|
| 805 |
+
|
| 806 |
+
self._log_event(
|
| 807 |
+
LogEvent.RUN_FAILED,
|
| 808 |
+
run_id_str,
|
| 809 |
+
error=str(e),
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
self.logger.error(
|
| 813 |
+
"Evaluation run failed",
|
| 814 |
+
run_id=run_id_str,
|
| 815 |
+
error=str(e),
|
| 816 |
+
)
|
| 817 |
+
|
| 818 |
+
finally:
|
| 819 |
+
# Clean up active run
|
| 820 |
+
self._active_runs.pop(run_id_str, None)
|
| 821 |
+
|
| 822 |
+
return output
|
| 823 |
+
|
| 824 |
+
async def _execute_model(
|
| 825 |
+
self,
|
| 826 |
+
prompt: str,
|
| 827 |
+
model_name: str,
|
| 828 |
+
) -> str:
|
| 829 |
+
"""
|
| 830 |
+
Execute model inference.
|
| 831 |
+
|
| 832 |
+
This is a placeholder. In production, this would call the actual model.
|
| 833 |
+
"""
|
| 834 |
+
# Placeholder: In production, this would call the actual model
|
| 835 |
+
# For now, simulate model execution
|
| 836 |
+
await asyncio.sleep(0.05) # Simulate inference time
|
| 837 |
+
|
| 838 |
+
return f"[Model response to: {prompt[:50]}...]"
|
| 839 |
+
|
| 840 |
+
async def _save_artifacts(
|
| 841 |
+
self,
|
| 842 |
+
output: EvaluationOutput,
|
| 843 |
+
config: RunConfig,
|
| 844 |
+
metadata: Optional[DatasetMetadata] = None,
|
| 845 |
+
) -> None:
|
| 846 |
+
"""Save evaluation artifacts to disk."""
|
| 847 |
+
artifacts_dir = Path(settings.experiment_artifacts_path)
|
| 848 |
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
| 849 |
+
|
| 850 |
+
artifact_file = artifacts_dir / f"{output.run_id}.json"
|
| 851 |
+
|
| 852 |
+
artifact_data = {
|
| 853 |
+
"run_id": output.run_id,
|
| 854 |
+
"config": config.model_dump(),
|
| 855 |
+
"model_name": output.model_name,
|
| 856 |
+
"model_version": output.model_version,
|
| 857 |
+
"dataset_version": output.dataset_version,
|
| 858 |
+
"dataset_metadata": metadata.model_dump() if metadata else None,
|
| 859 |
+
"status": output.status.value,
|
| 860 |
+
"composite_score": output.composite_score,
|
| 861 |
+
"metrics": output.metrics,
|
| 862 |
+
"performance": output.performance,
|
| 863 |
+
"started_at": output.started_at.isoformat(),
|
| 864 |
+
"completed_at": output.completed_at.isoformat() if output.completed_at else None,
|
| 865 |
+
"error": output.error,
|
| 866 |
+
"config_hash": config.get_config_hash(),
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
with open(artifact_file, "w") as f:
|
| 870 |
+
json.dump(artifact_data, f, indent=2)
|
| 871 |
+
|
| 872 |
+
self.logger.info(
|
| 873 |
+
"Artifacts saved",
|
| 874 |
+
run_id=output.run_id,
|
| 875 |
+
artifact_path=str(artifact_file),
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
async def get_run_status(self, run_id: str) -> Optional[EvaluationOutput]:
|
| 879 |
+
"""Get the status of an active or completed run."""
|
| 880 |
+
# Check if run is active
|
| 881 |
+
if run_id in self._active_runs:
|
| 882 |
+
return EvaluationOutput(
|
| 883 |
+
run_id=run_id,
|
| 884 |
+
status=RunStatus.RUNNING,
|
| 885 |
+
started_at=datetime.utcnow(),
|
| 886 |
+
)
|
| 887 |
+
|
| 888 |
+
# Try to load from artifacts
|
| 889 |
+
artifact_file = Path(settings.experiment_artifacts_path) / f"{run_id}.json"
|
| 890 |
+
|
| 891 |
+
if artifact_file.exists():
|
| 892 |
+
with open(artifact_file, "r") as f:
|
| 893 |
+
data = json.load(f)
|
| 894 |
+
|
| 895 |
+
return EvaluationOutput(
|
| 896 |
+
run_id=data["run_id"],
|
| 897 |
+
model_name=data["model_name"],
|
| 898 |
+
model_version=data["model_version"],
|
| 899 |
+
dataset_version=data["dataset_version"],
|
| 900 |
+
status=RunStatus(data["status"]),
|
| 901 |
+
composite_score=data.get("composite_score"),
|
| 902 |
+
metrics=data.get("metrics", {}),
|
| 903 |
+
performance=data.get("performance", {}),
|
| 904 |
+
started_at=datetime.fromisoformat(data["started_at"]),
|
| 905 |
+
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
| 906 |
+
error=data.get("error"),
|
| 907 |
+
)
|
| 908 |
+
|
| 909 |
+
return None
|
| 910 |
+
|
| 911 |
+
async def cancel_run(self, run_id: str) -> bool:
|
| 912 |
+
"""Cancel an active run."""
|
| 913 |
+
if run_id in self._active_runs:
|
| 914 |
+
task = self._active_runs[run_id]
|
| 915 |
+
task.cancel()
|
| 916 |
+
|
| 917 |
+
try:
|
| 918 |
+
await task
|
| 919 |
+
except asyncio.CancelledError:
|
| 920 |
+
pass
|
| 921 |
+
|
| 922 |
+
await self._update_run_status(
|
| 923 |
+
uuid.UUID(run_id),
|
| 924 |
+
RunStatus.CANCELLED,
|
| 925 |
+
)
|
| 926 |
+
|
| 927 |
+
self.logger.info("Run cancelled", run_id=run_id)
|
| 928 |
+
return True
|
| 929 |
+
|
| 930 |
+
return False
|
| 931 |
+
|
| 932 |
+
|
| 933 |
+
# =============================================================================
|
| 934 |
+
# Global Instance and Factory
|
| 935 |
+
# =============================================================================
|
| 936 |
+
|
| 937 |
+
orchestrator = EvaluationOrchestrator()
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
async def get_orchestrator() -> EvaluationOrchestrator:
|
| 941 |
+
"""Dependency injection for orchestrator."""
|
| 942 |
+
return orchestrator
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
__all__ = [
|
| 946 |
+
"EvaluationOrchestrator",
|
| 947 |
+
"EvaluationInput",
|
| 948 |
+
"EvaluationOutput",
|
| 949 |
+
"RunConfig",
|
| 950 |
+
"RunStatus",
|
| 951 |
+
"LogEvent",
|
| 952 |
+
"SampleResult",
|
| 953 |
+
"orchestrator",
|
| 954 |
+
"get_orchestrator",
|
| 955 |
+
]
|
backend/core/qu the routes.ota.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tenant Quota Management
|
| 3 |
+
|
| 4 |
+
Provides quota enforcement for job creation based on tenant limits.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import select, func
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
|
| 13 |
+
from backend.db.models import Tenant, EvaluationRun
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class QuotaExceededError(Exception):
|
| 17 |
+
"""Exception raised when tenant quota is exceeded."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, tenant_id: uuid.UUID, current: int, quota: int):
|
| 20 |
+
self.tenant_id = tenant_id
|
| 21 |
+
self.current = current
|
| 22 |
+
self.quota = quota
|
| 23 |
+
super().__init__(
|
| 24 |
+
f"Quota exceeded: tenant {tenant_id} has {current}/{quota} active jobs"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TenantQuotaManager:
|
| 29 |
+
"""
|
| 30 |
+
Manages tenant job quotas.
|
| 31 |
+
|
| 32 |
+
Enforces job quota limits before allowing job creation.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
@staticmethod
|
| 36 |
+
async def get_active_job_count(
|
| 37 |
+
db: AsyncSession,
|
| 38 |
+
tenant_id: uuid.UUID,
|
| 39 |
+
) -> int:
|
| 40 |
+
"""
|
| 41 |
+
Get the number of active jobs for a tenant.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
db: Database session
|
| 45 |
+
tenant_id: Tenant ID
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Number of active jobs (status in ['pending', 'running'])
|
| 49 |
+
"""
|
| 50 |
+
query = select(func.count(EvaluationRun.id)).where(
|
| 51 |
+
EvaluationRun.tenant_id == tenant_id,
|
| 52 |
+
EvaluationRun.status.in_(["pending", "running"]),
|
| 53 |
+
)
|
| 54 |
+
result = await db.execute(query)
|
| 55 |
+
return result.scalar() or 0
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
async def get_tenant_quota(
|
| 59 |
+
db: AsyncSession,
|
| 60 |
+
tenant_id: uuid.UUID,
|
| 61 |
+
) -> Optional[Tenant]:
|
| 62 |
+
"""
|
| 63 |
+
Get the tenant and their quota.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
db: Database session
|
| 67 |
+
tenant_id: Tenant ID
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Tenant object or None if not found
|
| 71 |
+
"""
|
| 72 |
+
query = select(Tenant).where(Tenant.id == tenant_id)
|
| 73 |
+
result = await db.execute(query)
|
| 74 |
+
return result.scalar_one_or_none()
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
async def check_quota(
|
| 78 |
+
db: AsyncSession,
|
| 79 |
+
tenant_id: uuid.UUID,
|
| 80 |
+
) -> tuple[int, int]:
|
| 81 |
+
"""
|
| 82 |
+
Check if tenant has available quota for new job.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
db: Database session
|
| 86 |
+
tenant_id: Tenant ID
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Tuple of (current_jobs, quota)
|
| 90 |
+
|
| 91 |
+
Raises:
|
| 92 |
+
QuotaExceededError: If quota is exceeded
|
| 93 |
+
"""
|
| 94 |
+
# Get tenant info
|
| 95 |
+
tenant = await TenantQuotaManager.get_tenant_quota(db, tenant_id)
|
| 96 |
+
|
| 97 |
+
if tenant is None:
|
| 98 |
+
# If tenant doesn't exist, use default quota
|
| 99 |
+
quota = 10
|
| 100 |
+
else:
|
| 101 |
+
quota = tenant.job_quota if tenant.active else 0
|
| 102 |
+
|
| 103 |
+
if quota <= 0:
|
| 104 |
+
raise QuotaExceededError(tenant_id, 0, quota)
|
| 105 |
+
|
| 106 |
+
# Get current active job count
|
| 107 |
+
current = await TenantQuotaManager.get_active_job_count(db, tenant_id)
|
| 108 |
+
|
| 109 |
+
if current >= quota:
|
| 110 |
+
raise QuotaExceededError(tenant_id, current, quota)
|
| 111 |
+
|
| 112 |
+
return current, quota
|
| 113 |
+
|
| 114 |
+
@staticmethod
|
| 115 |
+
async def enforce_quota(
|
| 116 |
+
db: AsyncSession,
|
| 117 |
+
tenant_id: uuid.UUID,
|
| 118 |
+
) -> None:
|
| 119 |
+
"""
|
| 120 |
+
Enforce quota before job creation.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
db: Database session
|
| 124 |
+
tenant_id: Tenant ID
|
| 125 |
+
|
| 126 |
+
Raises:
|
| 127 |
+
QuotaExceededError: If quota is exceeded
|
| 128 |
+
"""
|
| 129 |
+
current, quota = await TenantQuotaManager.check_quota(db, tenant_id)
|
| 130 |
+
|
| 131 |
+
# If we get here, quota is available
|
| 132 |
+
# The actual job creation should happen in a transaction
|
| 133 |
+
# that includes this check
|
| 134 |
+
|
| 135 |
+
@staticmethod
|
| 136 |
+
async def get_quota_info(
|
| 137 |
+
db: AsyncSession,
|
| 138 |
+
tenant_id: uuid.UUID,
|
| 139 |
+
) -> dict:
|
| 140 |
+
"""
|
| 141 |
+
Get quota information for a tenant.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
db: Database session
|
| 145 |
+
tenant_id: Tenant ID
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
Dictionary with quota information
|
| 149 |
+
"""
|
| 150 |
+
current = await TenantQuotaManager.get_active_job_count(db, tenant_id)
|
| 151 |
+
tenant = await TenantQuotaManager.get_tenant_quota(db, tenant_id)
|
| 152 |
+
|
| 153 |
+
quota = tenant.job_quota if tenant else 10
|
| 154 |
+
active = tenant.active if tenant else False
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"tenant_id": str(tenant_id),
|
| 158 |
+
"active": active,
|
| 159 |
+
"current_jobs": current,
|
| 160 |
+
"quota": quota,
|
| 161 |
+
"available": quota - current if active else 0,
|
| 162 |
+
"percent_used": (current / quota * 100) if quota > 0 else 0,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def check_job_quota(
|
| 167 |
+
db: AsyncSession,
|
| 168 |
+
tenant_id: uuid.UUID,
|
| 169 |
+
) -> None:
|
| 170 |
+
"""
|
| 171 |
+
Convenience function to check job quota.
|
| 172 |
+
|
| 173 |
+
Raises QuotaExceededError if quota is exceeded.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
db: Database session
|
| 177 |
+
tenant_id: Tenant ID
|
| 178 |
+
"""
|
| 179 |
+
await TenantQuotaManager.enforce_quota(db, tenant_id)
|
backend/core/quota.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tenant Quota Management
|
| 3 |
+
|
| 4 |
+
Provides quota enforcement for job creation based on tenant limits.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import select, func
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
|
| 13 |
+
from backend.db.models import Tenant, EvaluationRun
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class QuotaExceededError(Exception):
|
| 17 |
+
"""Exception raised when tenant quota is exceeded."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, tenant_id: uuid.UUID, current: int, quota: int):
|
| 20 |
+
self.tenant_id = tenant_id
|
| 21 |
+
self.current = current
|
| 22 |
+
self.quota = quota
|
| 23 |
+
super().__init__(
|
| 24 |
+
f"Quota exceeded: tenant {tenant_id} has {current}/{quota} active jobs"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TenantQuotaManager:
|
| 29 |
+
"""
|
| 30 |
+
Manages tenant job quotas.
|
| 31 |
+
|
| 32 |
+
Enforces job quota limits before allowing job creation.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
@staticmethod
|
| 36 |
+
async def get_active_job_count(
|
| 37 |
+
db: AsyncSession,
|
| 38 |
+
tenant_id: uuid.UUID,
|
| 39 |
+
) -> int:
|
| 40 |
+
"""
|
| 41 |
+
Get the number of active jobs for a tenant.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
db: Database session
|
| 45 |
+
tenant_id: Tenant ID
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Number of active jobs (status in ['pending', 'running'])
|
| 49 |
+
"""
|
| 50 |
+
query = select(func.count(EvaluationRun.id)).where(
|
| 51 |
+
EvaluationRun.tenant_id == tenant_id,
|
| 52 |
+
EvaluationRun.status.in_(["pending", "running"]),
|
| 53 |
+
)
|
| 54 |
+
result = await db.execute(query)
|
| 55 |
+
return result.scalar() or 0
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
async def get_tenant_quota(
|
| 59 |
+
db: AsyncSession,
|
| 60 |
+
tenant_id: uuid.UUID,
|
| 61 |
+
) -> Optional[Tenant]:
|
| 62 |
+
"""
|
| 63 |
+
Get the tenant and their quota.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
db: Database session
|
| 67 |
+
tenant_id: Tenant ID
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Tenant object or None if not found
|
| 71 |
+
"""
|
| 72 |
+
query = select(Tenant).where(Tenant.id == tenant_id)
|
| 73 |
+
result = await db.execute(query)
|
| 74 |
+
return result.scalar_one_or_none()
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
async def check_quota(
|
| 78 |
+
db: AsyncSession,
|
| 79 |
+
tenant_id: uuid.UUID,
|
| 80 |
+
) -> tuple[int, int]:
|
| 81 |
+
"""
|
| 82 |
+
Check if tenant has available quota for new job.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
db: Database session
|
| 86 |
+
tenant_id: Tenant ID
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Tuple of (current_jobs, quota)
|
| 90 |
+
|
| 91 |
+
Raises:
|
| 92 |
+
QuotaExceededError: If quota is exceeded
|
| 93 |
+
"""
|
| 94 |
+
# Get tenant info
|
| 95 |
+
tenant = await TenantQuotaManager.get_tenant_quota(db, tenant_id)
|
| 96 |
+
|
| 97 |
+
if tenant is None:
|
| 98 |
+
# If tenant doesn't exist, use default quota
|
| 99 |
+
quota = 10
|
| 100 |
+
else:
|
| 101 |
+
quota = tenant.job_quota if tenant.active else 0
|
| 102 |
+
|
| 103 |
+
if quota <= 0:
|
| 104 |
+
raise QuotaExceededError(tenant_id, 0, quota)
|
| 105 |
+
|
| 106 |
+
# Get current active job count
|
| 107 |
+
current = await TenantQuotaManager.get_active_job_count(db, tenant_id)
|
| 108 |
+
|
| 109 |
+
if current >= quota:
|
| 110 |
+
raise QuotaExceededError(tenant_id, current, quota)
|
| 111 |
+
|
| 112 |
+
return current, quota
|
| 113 |
+
|
| 114 |
+
@staticmethod
|
| 115 |
+
async def enforce_quota(
|
| 116 |
+
db: AsyncSession,
|
| 117 |
+
tenant_id: uuid.UUID,
|
| 118 |
+
) -> None:
|
| 119 |
+
"""
|
| 120 |
+
Enforce quota before job creation.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
db: Database session
|
| 124 |
+
tenant_id: Tenant ID
|
| 125 |
+
|
| 126 |
+
Raises:
|
| 127 |
+
QuotaExceededError: If quota is exceeded
|
| 128 |
+
"""
|
| 129 |
+
current, quota = await TenantQuotaManager.check_quota(db, tenant_id)
|
| 130 |
+
|
| 131 |
+
# If we get here, quota is available
|
| 132 |
+
# The actual job creation should happen in a transaction
|
| 133 |
+
# that includes this check
|
| 134 |
+
|
| 135 |
+
@staticmethod
|
| 136 |
+
async def get_quota_info(
|
| 137 |
+
db: AsyncSession,
|
| 138 |
+
tenant_id: uuid.UUID,
|
| 139 |
+
) -> dict:
|
| 140 |
+
"""
|
| 141 |
+
Get quota information for a tenant.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
db: Database session
|
| 145 |
+
tenant_id: Tenant ID
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
Dictionary with quota information
|
| 149 |
+
"""
|
| 150 |
+
current = await TenantQuotaManager.get_active_job_count(db, tenant_id)
|
| 151 |
+
tenant = await TenantQuotaManager.get_tenant_quota(db, tenant_id)
|
| 152 |
+
|
| 153 |
+
quota = tenant.job_quota if tenant else 10
|
| 154 |
+
active = tenant.active if tenant else False
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"tenant_id": str(tenant_id),
|
| 158 |
+
"active": active,
|
| 159 |
+
"current_jobs": current,
|
| 160 |
+
"quota": quota,
|
| 161 |
+
"available": quota - current if active else 0,
|
| 162 |
+
"percent_used": (current / quota * 100) if quota > 0 else 0,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def check_job_quota(
|
| 167 |
+
db: AsyncSession,
|
| 168 |
+
tenant_id: uuid.UUID,
|
| 169 |
+
) -> None:
|
| 170 |
+
"""
|
| 171 |
+
Convenience function to check job quota.
|
| 172 |
+
|
| 173 |
+
Raises QuotaExceededError if quota is exceeded.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
db: Database session
|
| 177 |
+
tenant_id: Tenant ID
|
| 178 |
+
"""
|
| 179 |
+
await TenantQuotaManager.enforce_quota(db, tenant_id)
|
backend/monitoring/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Monitoring Module
|
| 3 |
+
|
| 4 |
+
Continuous AI Governance Infrastructure for AegisLM.
|
| 5 |
+
|
| 6 |
+
Provides:
|
| 7 |
+
- Streaming evaluation pipeline for real-time monitoring
|
| 8 |
+
- Drift detection with statistical analysis
|
| 9 |
+
- Alert generation and management
|
| 10 |
+
- Dashboard data API
|
| 11 |
+
|
| 12 |
+
Architecture:
|
| 13 |
+
Live Prompt Stream
|
| 14 |
+
↓
|
| 15 |
+
Streaming Evaluator
|
| 16 |
+
↓
|
| 17 |
+
Defender + Judge
|
| 18 |
+
↓
|
| 19 |
+
Rolling Metrics Store
|
| 20 |
+
↓
|
| 21 |
+
Drift Detection
|
| 22 |
+
↓
|
| 23 |
+
Alerting Engine
|
| 24 |
+
↓
|
| 25 |
+
Dashboard (Monitoring Tab)
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from .alerting import (
|
| 29 |
+
AlertManager,
|
| 30 |
+
AlertSeverity,
|
| 31 |
+
AlertSummary,
|
| 32 |
+
AlertType,
|
| 33 |
+
get_alert_manager,
|
| 34 |
+
)
|
| 35 |
+
from .drift_detection import (
|
| 36 |
+
DriftDetector,
|
| 37 |
+
DriftDetectionResult,
|
| 38 |
+
MetricWindow,
|
| 39 |
+
get_drift_detector,
|
| 40 |
+
)
|
| 41 |
+
from .pipeline import (
|
| 42 |
+
MonitoringPipeline,
|
| 43 |
+
MonitoringConfig,
|
| 44 |
+
MonitoringDashboardData,
|
| 45 |
+
get_monitoring_pipeline,
|
| 46 |
+
)
|
| 47 |
+
from .schemas import (
|
| 48 |
+
Alert,
|
| 49 |
+
AlertSeverity,
|
| 50 |
+
AlertSummary,
|
| 51 |
+
AlertType,
|
| 52 |
+
DriftDetectionResult,
|
| 53 |
+
MonitoringConfig,
|
| 54 |
+
MonitoringDashboardData,
|
| 55 |
+
MonitoringRequest,
|
| 56 |
+
MonitoringResponse,
|
| 57 |
+
RollingMetrics,
|
| 58 |
+
)
|
| 59 |
+
from .streaming_evaluator import (
|
| 60 |
+
StreamingEvaluator,
|
| 61 |
+
get_streaming_evaluator,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
__all__ = [
|
| 65 |
+
# Pipeline
|
| 66 |
+
"MonitoringPipeline",
|
| 67 |
+
"MonitoringConfig",
|
| 68 |
+
"MonitoringDashboardData",
|
| 69 |
+
"get_monitoring_pipeline",
|
| 70 |
+
|
| 71 |
+
# Streaming Evaluator
|
| 72 |
+
"StreamingEvaluator",
|
| 73 |
+
"get_streaming_evaluator",
|
| 74 |
+
|
| 75 |
+
# Drift Detection
|
| 76 |
+
"DriftDetector",
|
| 77 |
+
"DriftDetectionResult",
|
| 78 |
+
"MetricWindow",
|
| 79 |
+
"get_drift_detector",
|
| 80 |
+
|
| 81 |
+
# Alerting
|
| 82 |
+
"AlertManager",
|
| 83 |
+
"Alert",
|
| 84 |
+
"AlertType",
|
| 85 |
+
"AlertSeverity",
|
| 86 |
+
"AlertSummary",
|
| 87 |
+
"get_alert_manager",
|
| 88 |
+
|
| 89 |
+
# Schemas
|
| 90 |
+
"MonitoringRequest",
|
| 91 |
+
"MonitoringResponse",
|
| 92 |
+
"RollingMetrics",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
# Version
|
| 96 |
+
__version__ = "0.1.0"
|
backend/monitoring/alerting.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alerting Engine
|
| 3 |
+
|
| 4 |
+
Alert generation and management for drift detection.
|
| 5 |
+
Coordinates alert creation, storage, and resolution.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from backend.logging.logger import get_logger
|
| 14 |
+
|
| 15 |
+
from .schemas import (
|
| 16 |
+
Alert,
|
| 17 |
+
AlertSeverity,
|
| 18 |
+
AlertSummary,
|
| 19 |
+
AlertType,
|
| 20 |
+
DriftDetectionResult,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AlertManager:
|
| 25 |
+
"""
|
| 26 |
+
Alert management engine.
|
| 27 |
+
|
| 28 |
+
Handles:
|
| 29 |
+
- Alert creation from drift detection
|
| 30 |
+
- Alert storage and retrieval
|
| 31 |
+
- Alert resolution
|
| 32 |
+
- Alert aggregation for dashboard
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self) -> None:
|
| 36 |
+
"""Initialize alert manager."""
|
| 37 |
+
self.logger = get_logger(__name__)
|
| 38 |
+
|
| 39 |
+
# Active alerts by type
|
| 40 |
+
self._active_alerts: Dict[AlertType, Alert] = {}
|
| 41 |
+
|
| 42 |
+
# All alerts (including resolved)
|
| 43 |
+
self._all_alerts: List[Alert] = []
|
| 44 |
+
|
| 45 |
+
# Alerts by model version
|
| 46 |
+
self._alerts_by_model: Dict[str, List[Alert]] = defaultdict(list)
|
| 47 |
+
|
| 48 |
+
def create_alert_from_drift(
|
| 49 |
+
self,
|
| 50 |
+
drift_result: DriftDetectionResult,
|
| 51 |
+
model_version: str,
|
| 52 |
+
) -> Alert:
|
| 53 |
+
"""
|
| 54 |
+
Create an alert from drift detection result.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
drift_result: Drift detection result
|
| 58 |
+
model_version: Model version being monitored
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Created alert
|
| 62 |
+
"""
|
| 63 |
+
# Determine alert type from metric name
|
| 64 |
+
alert_type = self._metric_to_alert_type(drift_result.metric_name)
|
| 65 |
+
|
| 66 |
+
# Check if we already have an active alert for this metric
|
| 67 |
+
if alert_type in self._active_alerts:
|
| 68 |
+
existing_alert = self._active_alerts[alert_type]
|
| 69 |
+
|
| 70 |
+
# Update existing alert if severity increased
|
| 71 |
+
if drift_result.severity.value > existing_alert.severity.value:
|
| 72 |
+
self.logger.info(
|
| 73 |
+
"Updating existing alert with increased severity",
|
| 74 |
+
alert_type=alert_type,
|
| 75 |
+
old_severity=existing_alert.severity,
|
| 76 |
+
new_severity=drift_result.severity,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Create new alert with updated info
|
| 80 |
+
alert = Alert(
|
| 81 |
+
id=str(uuid.uuid4()),
|
| 82 |
+
alert_type=alert_type,
|
| 83 |
+
severity=drift_result.severity,
|
| 84 |
+
model_version=model_version,
|
| 85 |
+
metric_name=drift_result.metric_name,
|
| 86 |
+
baseline_value=drift_result.baseline_value,
|
| 87 |
+
current_value=drift_result.live_value,
|
| 88 |
+
drift_magnitude=drift_result.drift_magnitude,
|
| 89 |
+
threshold=drift_result.threshold,
|
| 90 |
+
timestamp=datetime.utcnow(),
|
| 91 |
+
is_resolved=False,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
self._active_alerts[alert_type] = alert
|
| 95 |
+
self._all_alerts.append(alert)
|
| 96 |
+
self._alerts_by_model[model_version].append(alert)
|
| 97 |
+
|
| 98 |
+
return alert
|
| 99 |
+
|
| 100 |
+
# Otherwise, just update the current value and timestamp
|
| 101 |
+
existing_alert.current_value = drift_result.live_value
|
| 102 |
+
existing_alert.drift_magnitude = drift_result.drift_magnitude
|
| 103 |
+
existing_alert.timestamp = datetime.utcnow()
|
| 104 |
+
|
| 105 |
+
return existing_alert
|
| 106 |
+
|
| 107 |
+
# Create new alert
|
| 108 |
+
alert = Alert(
|
| 109 |
+
id=str(uuid.uuid4()),
|
| 110 |
+
alert_type=alert_type,
|
| 111 |
+
severity=drift_result.severity,
|
| 112 |
+
model_version=model_version,
|
| 113 |
+
metric_name=drift_result.metric_name,
|
| 114 |
+
baseline_value=drift_result.baseline_value,
|
| 115 |
+
current_value=drift_result.live_value,
|
| 116 |
+
drift_magnitude=drift_result.drift_magnitude,
|
| 117 |
+
threshold=drift_result.threshold,
|
| 118 |
+
timestamp=datetime.utcnow(),
|
| 119 |
+
is_resolved=False,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Store alert
|
| 123 |
+
self._active_alerts[alert_type] = alert
|
| 124 |
+
self._all_alerts.append(alert)
|
| 125 |
+
self._alerts_by_model[model_version].append(alert)
|
| 126 |
+
|
| 127 |
+
self.logger.warning(
|
| 128 |
+
"Alert created",
|
| 129 |
+
alert_id=alert.id,
|
| 130 |
+
alert_type=alert_type.value,
|
| 131 |
+
severity=drift_result.severity.value,
|
| 132 |
+
model_version=model_version,
|
| 133 |
+
drift_magnitude=drift_result.drift_magnitude,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return alert
|
| 137 |
+
|
| 138 |
+
def resolve_alert(
|
| 139 |
+
self,
|
| 140 |
+
alert_type: AlertType,
|
| 141 |
+
model_version: Optional[str] = None,
|
| 142 |
+
) -> bool:
|
| 143 |
+
"""
|
| 144 |
+
Resolve an active alert.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
alert_type: Type of alert to resolve
|
| 148 |
+
model_version: Optional model version to filter by
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
True if alert was resolved, False if not found
|
| 152 |
+
"""
|
| 153 |
+
if alert_type not in self._active_alerts:
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
alert = self._active_alerts[alert_type]
|
| 157 |
+
|
| 158 |
+
# Check model version if specified
|
| 159 |
+
if model_version and alert.model_version != model_version:
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
# Mark as resolved
|
| 163 |
+
alert.is_resolved = True
|
| 164 |
+
alert.resolved_at = datetime.utcnow()
|
| 165 |
+
|
| 166 |
+
# Remove from active alerts
|
| 167 |
+
del self._active_alerts[alert_type]
|
| 168 |
+
|
| 169 |
+
self.logger.info(
|
| 170 |
+
"Alert resolved",
|
| 171 |
+
alert_id=alert.id,
|
| 172 |
+
alert_type=alert_type.value,
|
| 173 |
+
model_version=alert.model_version,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return True
|
| 177 |
+
|
| 178 |
+
def resolve_all_alerts(self, model_version: Optional[str] = None) -> int:
|
| 179 |
+
"""
|
| 180 |
+
Resolve all active alerts.
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
model_version: Optional model version to filter by
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Number of alerts resolved
|
| 187 |
+
"""
|
| 188 |
+
count = 0
|
| 189 |
+
|
| 190 |
+
if model_version:
|
| 191 |
+
# Resolve specific model's alerts
|
| 192 |
+
alert_types_to_resolve = [
|
| 193 |
+
at for at, alert in self._active_alerts.items()
|
| 194 |
+
if alert.model_version == model_version
|
| 195 |
+
]
|
| 196 |
+
for alert_type in alert_types_to_resolve:
|
| 197 |
+
if self.resolve_alert(alert_type, model_version):
|
| 198 |
+
count += 1
|
| 199 |
+
else:
|
| 200 |
+
# Resolve all
|
| 201 |
+
count = len(self._active_alerts)
|
| 202 |
+
for alert_type in list(self._active_alerts.keys()):
|
| 203 |
+
self.resolve_alert(alert_type)
|
| 204 |
+
|
| 205 |
+
return count
|
| 206 |
+
|
| 207 |
+
def get_active_alerts(self) -> List[Alert]:
|
| 208 |
+
"""
|
| 209 |
+
Get all active alerts.
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
List of active alerts
|
| 213 |
+
"""
|
| 214 |
+
return list(self._active_alerts.values())
|
| 215 |
+
|
| 216 |
+
def get_alerts_by_model(
|
| 217 |
+
self,
|
| 218 |
+
model_version: str,
|
| 219 |
+
include_resolved: bool = False,
|
| 220 |
+
) -> List[Alert]:
|
| 221 |
+
"""
|
| 222 |
+
Get alerts for a specific model version.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
model_version: Model version to filter by
|
| 226 |
+
include_resolved: Include resolved alerts
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
List of alerts
|
| 230 |
+
"""
|
| 231 |
+
alerts = self._alerts_by_model.get(model_version, [])
|
| 232 |
+
|
| 233 |
+
if not include_resolved:
|
| 234 |
+
alerts = [a for a in alerts if not a.is_resolved]
|
| 235 |
+
|
| 236 |
+
return alerts
|
| 237 |
+
|
| 238 |
+
def get_alert_summary(self, limit: int = 10) -> AlertSummary:
|
| 239 |
+
"""
|
| 240 |
+
Get summary of alerts for dashboard.
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
limit: Number of recent alerts to include
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
Alert summary
|
| 247 |
+
"""
|
| 248 |
+
active_alerts = self.get_active_alerts()
|
| 249 |
+
|
| 250 |
+
# Count by severity
|
| 251 |
+
critical_count = sum(1 for a in active_alerts if a.severity == AlertSeverity.CRITICAL)
|
| 252 |
+
high_count = sum(1 for a in active_alerts if a.severity == AlertSeverity.HIGH)
|
| 253 |
+
medium_count = sum(1 for a in active_alerts if a.severity == AlertSeverity.MEDIUM)
|
| 254 |
+
low_count = sum(1 for a in active_alerts if a.severity == AlertSeverity.LOW)
|
| 255 |
+
|
| 256 |
+
# Get recent alerts (all, not just active)
|
| 257 |
+
recent_alerts = sorted(
|
| 258 |
+
self._all_alerts,
|
| 259 |
+
key=lambda a: a.timestamp,
|
| 260 |
+
reverse=True,
|
| 261 |
+
)[:limit]
|
| 262 |
+
|
| 263 |
+
return AlertSummary(
|
| 264 |
+
total_alerts=len(active_alerts),
|
| 265 |
+
critical_count=critical_count,
|
| 266 |
+
high_count=high_count,
|
| 267 |
+
medium_count=medium_count,
|
| 268 |
+
low_count=low_count,
|
| 269 |
+
recent_alerts=recent_alerts,
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
def get_alert(self, alert_id: str) -> Optional[Alert]:
|
| 273 |
+
"""
|
| 274 |
+
Get a specific alert by ID.
|
| 275 |
+
|
| 276 |
+
Args:
|
| 277 |
+
alert_id: Alert ID
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
Alert or None if not found
|
| 281 |
+
"""
|
| 282 |
+
for alert in self._all_alerts:
|
| 283 |
+
if alert.id == alert_id:
|
| 284 |
+
return alert
|
| 285 |
+
return None
|
| 286 |
+
|
| 287 |
+
def _metric_to_alert_type(self, metric_name: str) -> AlertType:
|
| 288 |
+
"""Convert metric name to alert type."""
|
| 289 |
+
mapping = {
|
| 290 |
+
"hallucination": AlertType.HALLUCINATION_DRIFT,
|
| 291 |
+
"toxicity": AlertType.TOXICITY_DRIFT,
|
| 292 |
+
"bias": AlertType.BIAS_DRIFT,
|
| 293 |
+
"confidence": AlertType.CONFIDENCE_COLLAPSE,
|
| 294 |
+
"robustness": AlertType.ROBUSTNESS_COLLAPSE,
|
| 295 |
+
}
|
| 296 |
+
return mapping.get(metric_name, AlertType.HALLUCINATION_DRIFT)
|
| 297 |
+
|
| 298 |
+
def clear_all_alerts(self) -> None:
|
| 299 |
+
"""Clear all alerts (for testing)."""
|
| 300 |
+
self._active_alerts.clear()
|
| 301 |
+
self._all_alerts.clear()
|
| 302 |
+
self._alerts_by_model.clear()
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# Global alert manager instance
|
| 306 |
+
_alert_manager: Optional[AlertManager] = None
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def get_alert_manager() -> AlertManager:
|
| 310 |
+
"""
|
| 311 |
+
Get the global alert manager instance.
|
| 312 |
+
|
| 313 |
+
Returns:
|
| 314 |
+
AlertManager singleton
|
| 315 |
+
"""
|
| 316 |
+
global _alert_manager
|
| 317 |
+
if _alert_manager is None:
|
| 318 |
+
_alert_manager = AlertManager()
|
| 319 |
+
return _alert_manager
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
__all__ = [
|
| 323 |
+
"AlertManager",
|
| 324 |
+
"Alert",
|
| 325 |
+
"AlertSummary",
|
| 326 |
+
"AlertType",
|
| 327 |
+
"AlertSeverity",
|
| 328 |
+
"get_alert_manager",
|
| 329 |
+
]
|
backend/monitoring/drift_detection.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Drift Detection Module
|
| 3 |
+
|
| 4 |
+
Statistical drift detection for monitoring metrics.
|
| 5 |
+
Implements rolling window analysis and threshold-based alerting.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from collections import deque
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Deque, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
from backend.logging.logger import get_logger
|
| 15 |
+
|
| 16 |
+
from .schemas import (
|
| 17 |
+
AlertSeverity,
|
| 18 |
+
AlertType,
|
| 19 |
+
DriftDetectionResult,
|
| 20 |
+
MonitoringConfig,
|
| 21 |
+
RollingMetrics,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class MetricWindow:
|
| 27 |
+
"""Rolling window for a specific metric."""
|
| 28 |
+
|
| 29 |
+
metric_name: str
|
| 30 |
+
values: Deque[float] = field(default_factory=deque)
|
| 31 |
+
timestamps: Deque[datetime] = field(default_factory=deque)
|
| 32 |
+
window_size: int = 100
|
| 33 |
+
max_stored_events: int = 10000 # Storage control: max events to retain
|
| 34 |
+
|
| 35 |
+
def add(self, value: float, timestamp: datetime) -> None:
|
| 36 |
+
"""Add a new value to the window."""
|
| 37 |
+
self.values.append(value)
|
| 38 |
+
self.timestamps.append(timestamp)
|
| 39 |
+
|
| 40 |
+
# Maintain window size for rolling calculations
|
| 41 |
+
while len(self.values) > self.window_size:
|
| 42 |
+
self.values.popleft()
|
| 43 |
+
self.timestamps.popleft()
|
| 44 |
+
|
| 45 |
+
# Storage control: Prune if exceeding max stored events
|
| 46 |
+
self._prune_if_needed()
|
| 47 |
+
|
| 48 |
+
def _prune_if_needed(self) -> None:
|
| 49 |
+
"""Prune oldest events if exceeding max storage limit."""
|
| 50 |
+
if len(self.values) > self.max_stored_events:
|
| 51 |
+
# Keep only the most recent max_stored_events
|
| 52 |
+
excess = len(self.values) - self.max_stored_events
|
| 53 |
+
for _ in range(excess):
|
| 54 |
+
self.values.popleft()
|
| 55 |
+
self.timestamps.popleft()
|
| 56 |
+
|
| 57 |
+
def get_storage_stats(self) -> dict:
|
| 58 |
+
"""Get storage statistics for this window."""
|
| 59 |
+
return {
|
| 60 |
+
"metric_name": self.metric_name,
|
| 61 |
+
"current_size": len(self.values),
|
| 62 |
+
"max_stored_events": self.max_stored_events,
|
| 63 |
+
"storage_usage_pct": (len(self.values) / self.max_stored_events) * 100,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
def clear(self) -> None:
|
| 67 |
+
"""Clear all stored data."""
|
| 68 |
+
self.values.clear()
|
| 69 |
+
self.timestamps.clear()
|
| 70 |
+
|
| 71 |
+
def compute_metrics(self) -> RollingMetrics:
|
| 72 |
+
"""Compute rolling statistics."""
|
| 73 |
+
if not self.values:
|
| 74 |
+
return RollingMetrics(
|
| 75 |
+
metric_name=self.metric_name,
|
| 76 |
+
current_value=0.0,
|
| 77 |
+
window_size=self.window_size,
|
| 78 |
+
sample_count=0,
|
| 79 |
+
min_value=0.0,
|
| 80 |
+
max_value=0.0,
|
| 81 |
+
std_dev=0.0,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
values_list = list(self.values)
|
| 85 |
+
n = len(values_list)
|
| 86 |
+
|
| 87 |
+
# Mean
|
| 88 |
+
current_value = sum(values_list) / n
|
| 89 |
+
|
| 90 |
+
# Min/Max
|
| 91 |
+
min_value = min(values_list)
|
| 92 |
+
max_value = max(values_list)
|
| 93 |
+
|
| 94 |
+
# Standard deviation
|
| 95 |
+
variance = sum((x - current_value) ** 2 for x in values_list) / n
|
| 96 |
+
std_dev = variance ** 0.5
|
| 97 |
+
|
| 98 |
+
return RollingMetrics(
|
| 99 |
+
metric_name=self.metric_name,
|
| 100 |
+
current_value=current_value,
|
| 101 |
+
window_size=self.window_size,
|
| 102 |
+
sample_count=n,
|
| 103 |
+
min_value=min_value,
|
| 104 |
+
max_value=max_value,
|
| 105 |
+
std_dev=std_dev,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class DriftDetector:
|
| 110 |
+
"""
|
| 111 |
+
Drift detection engine for monitoring metrics.
|
| 112 |
+
|
| 113 |
+
Implements:
|
| 114 |
+
- Statistical drift detection (baseline vs live)
|
| 115 |
+
- Confidence collapse detection
|
| 116 |
+
- Threshold-based alerting
|
| 117 |
+
|
| 118 |
+
Mathematical definitions:
|
| 119 |
+
- Drift(H) = |mean(H_live) - mean(H_baseline)|
|
| 120 |
+
- Alert if Drift(metric) > threshold
|
| 121 |
+
"""
|
| 122 |
+
|
| 123 |
+
def __init__(
|
| 124 |
+
self,
|
| 125 |
+
config: Optional[MonitoringConfig] = None,
|
| 126 |
+
) -> None:
|
| 127 |
+
"""
|
| 128 |
+
Initialize drift detector.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
config: Monitoring configuration
|
| 132 |
+
"""
|
| 133 |
+
self.logger = get_logger(__name__)
|
| 134 |
+
self._config = config or MonitoringConfig()
|
| 135 |
+
|
| 136 |
+
# Baseline windows (fixed reference)
|
| 137 |
+
self._baseline_windows: Dict[str, MetricWindow] = {}
|
| 138 |
+
|
| 139 |
+
# Live rolling windows
|
| 140 |
+
self._live_windows: Dict[str, MetricWindow] = {
|
| 141 |
+
"hallucination": MetricWindow("hallucination", window_size=self._config.window_size),
|
| 142 |
+
"toxicity": MetricWindow("toxicity", window_size=self._config.window_size),
|
| 143 |
+
"bias": MetricWindow("bias", window_size=self._config.window_size),
|
| 144 |
+
"confidence": MetricWindow("confidence", window_size=self._config.window_size),
|
| 145 |
+
"robustness": MetricWindow("robustness", window_size=self._config.window_size),
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# Baseline values (established during initial period)
|
| 149 |
+
self._baseline_values: Dict[str, float] = {}
|
| 150 |
+
self._baseline_established: bool = False
|
| 151 |
+
|
| 152 |
+
def update_baseline(self, baseline_values: Dict[str, float]) -> None:
|
| 153 |
+
"""
|
| 154 |
+
Update baseline values for drift detection.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
baseline_values: Dictionary of baseline metric values
|
| 158 |
+
"""
|
| 159 |
+
self.logger.info("Updating baseline values", baseline_values=baseline_values)
|
| 160 |
+
|
| 161 |
+
for metric_name, value in baseline_values.items():
|
| 162 |
+
if metric_name in self._baseline_windows:
|
| 163 |
+
# Add to baseline window
|
| 164 |
+
self._baseline_windows[metric_name].add(value, datetime.utcnow())
|
| 165 |
+
else:
|
| 166 |
+
# Create new baseline window
|
| 167 |
+
window = MetricWindow(metric_name, window_size=self._config.window_size)
|
| 168 |
+
window.add(value, datetime.utcnow())
|
| 169 |
+
self._baseline_windows[metric_name] = window
|
| 170 |
+
|
| 171 |
+
self._baseline_values[metric_name] = value
|
| 172 |
+
|
| 173 |
+
self._baseline_established = True
|
| 174 |
+
self.logger.info("Baseline established", metrics=list(self._baseline_values.keys()))
|
| 175 |
+
|
| 176 |
+
def record_metric(
|
| 177 |
+
self,
|
| 178 |
+
metric_name: str,
|
| 179 |
+
value: float,
|
| 180 |
+
timestamp: Optional[datetime] = None,
|
| 181 |
+
) -> None:
|
| 182 |
+
"""
|
| 183 |
+
Record a new metric value.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
metric_name: Name of the metric
|
| 187 |
+
value: Metric value
|
| 188 |
+
timestamp: Timestamp (defaults to now)
|
| 189 |
+
"""
|
| 190 |
+
if timestamp is None:
|
| 191 |
+
timestamp = datetime.utcnow()
|
| 192 |
+
|
| 193 |
+
if metric_name in self._live_windows:
|
| 194 |
+
self._live_windows[metric_name].add(value, timestamp)
|
| 195 |
+
|
| 196 |
+
# Auto-establish baseline from first values if not set
|
| 197 |
+
if not self._baseline_established and metric_name not in self._baseline_values:
|
| 198 |
+
if self._live_windows[metric_name].compute_metrics().sample_count >= self._config.min_window_samples:
|
| 199 |
+
# Use initial rolling average as baseline
|
| 200 |
+
rolling = self._live_windows[metric_name].compute_metrics()
|
| 201 |
+
self._baseline_values[metric_name] = rolling.current_value
|
| 202 |
+
self.logger.info(f"Auto-established baseline for {metric_name}", baseline=rolling.current_value)
|
| 203 |
+
|
| 204 |
+
# Check if all baselines are established
|
| 205 |
+
if all(m in self._baseline_values for m in self._live_windows.keys()):
|
| 206 |
+
self._baseline_established = True
|
| 207 |
+
|
| 208 |
+
def detect_drift(self, metric_name: str) -> DriftDetectionResult:
|
| 209 |
+
"""
|
| 210 |
+
Detect drift for a specific metric.
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
metric_name: Name of the metric to check
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
Drift detection result
|
| 217 |
+
"""
|
| 218 |
+
if metric_name not in self._live_windows:
|
| 219 |
+
return DriftDetectionResult(
|
| 220 |
+
metric_name=metric_name,
|
| 221 |
+
baseline_value=0.0,
|
| 222 |
+
live_value=0.0,
|
| 223 |
+
drift_magnitude=0.0,
|
| 224 |
+
threshold=0.0,
|
| 225 |
+
is_drift_detected=False,
|
| 226 |
+
severity=AlertSeverity.LOW,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Get baseline value
|
| 230 |
+
baseline_value = self._baseline_values.get(metric_name, 0.0)
|
| 231 |
+
|
| 232 |
+
# Get live rolling metrics
|
| 233 |
+
live_window = self._live_windows[metric_name]
|
| 234 |
+
live_metrics = live_window.compute_metrics()
|
| 235 |
+
|
| 236 |
+
if live_metrics.sample_count < self._config.min_window_samples:
|
| 237 |
+
# Not enough samples yet
|
| 238 |
+
return DriftDetectionResult(
|
| 239 |
+
metric_name=metric_name,
|
| 240 |
+
baseline_value=baseline_value,
|
| 241 |
+
live_value=live_metrics.current_value,
|
| 242 |
+
drift_magnitude=0.0,
|
| 243 |
+
threshold=self._get_threshold(metric_name),
|
| 244 |
+
is_drift_detected=False,
|
| 245 |
+
severity=AlertSeverity.LOW,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# Calculate drift magnitude
|
| 249 |
+
drift_magnitude = abs(live_metrics.current_value - baseline_value)
|
| 250 |
+
|
| 251 |
+
# Get threshold for this metric
|
| 252 |
+
threshold = self._get_threshold(metric_name)
|
| 253 |
+
|
| 254 |
+
# Determine if drift exceeds threshold
|
| 255 |
+
is_drift_detected = drift_magnitude > threshold
|
| 256 |
+
|
| 257 |
+
# Calculate severity
|
| 258 |
+
severity = self._calculate_severity(drift_magnitude, threshold)
|
| 259 |
+
|
| 260 |
+
result = DriftDetectionResult(
|
| 261 |
+
metric_name=metric_name,
|
| 262 |
+
baseline_value=baseline_value,
|
| 263 |
+
live_value=live_metrics.current_value,
|
| 264 |
+
drift_magnitude=drift_magnitude,
|
| 265 |
+
threshold=threshold,
|
| 266 |
+
is_drift_detected=is_drift_detected,
|
| 267 |
+
severity=severity,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if is_drift_detected:
|
| 271 |
+
self.logger.warning(
|
| 272 |
+
"Drift detected",
|
| 273 |
+
metric_name=metric_name,
|
| 274 |
+
baseline_value=baseline_value,
|
| 275 |
+
live_value=live_metrics.current_value,
|
| 276 |
+
drift_magnitude=drift_magnitude,
|
| 277 |
+
threshold=threshold,
|
| 278 |
+
severity=severity,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
return result
|
| 282 |
+
|
| 283 |
+
def detect_all_drift(self) -> Dict[str, DriftDetectionResult]:
|
| 284 |
+
"""
|
| 285 |
+
Detect drift for all metrics.
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
Dictionary of drift detection results by metric
|
| 289 |
+
"""
|
| 290 |
+
results = {}
|
| 291 |
+
|
| 292 |
+
for metric_name in self._live_windows.keys():
|
| 293 |
+
results[metric_name] = self.detect_drift(metric_name)
|
| 294 |
+
|
| 295 |
+
return results
|
| 296 |
+
|
| 297 |
+
def get_rolling_metrics(self, metric_name: str) -> Optional[RollingMetrics]:
|
| 298 |
+
"""
|
| 299 |
+
Get rolling metrics for a specific metric.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
metric_name: Name of the metric
|
| 303 |
+
|
| 304 |
+
Returns:
|
| 305 |
+
Rolling metrics or None if not found
|
| 306 |
+
"""
|
| 307 |
+
if metric_name in self._live_windows:
|
| 308 |
+
return self._live_windows[metric_name].compute_metrics()
|
| 309 |
+
return None
|
| 310 |
+
|
| 311 |
+
def get_all_rolling_metrics(self) -> Dict[str, RollingMetrics]:
|
| 312 |
+
"""
|
| 313 |
+
Get rolling metrics for all metrics.
|
| 314 |
+
|
| 315 |
+
Returns:
|
| 316 |
+
Dictionary of rolling metrics by metric name
|
| 317 |
+
"""
|
| 318 |
+
return {
|
| 319 |
+
name: window.compute_metrics()
|
| 320 |
+
for name, window in self._live_windows.items()
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
def get_trend_data(self, metric_name: str, limit: int = 50) -> List[float]:
|
| 324 |
+
"""
|
| 325 |
+
Get recent trend data for a metric.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
metric_name: Name of the metric
|
| 329 |
+
limit: Maximum number of values to return
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
List of recent values
|
| 333 |
+
"""
|
| 334 |
+
if metric_name not in self._live_windows:
|
| 335 |
+
return []
|
| 336 |
+
|
| 337 |
+
values = list(self._live_windows[metric_name].values)
|
| 338 |
+
return values[-limit:] if len(values) > limit else values
|
| 339 |
+
|
| 340 |
+
def _get_threshold(self, metric_name: str) -> float:
|
| 341 |
+
"""Get threshold for a specific metric."""
|
| 342 |
+
threshold_map = {
|
| 343 |
+
"hallucination": self._config.hallucination_threshold,
|
| 344 |
+
"toxicity": self._config.toxicity_threshold,
|
| 345 |
+
"bias": self._config.bias_threshold,
|
| 346 |
+
"confidence": self._config.confidence_threshold,
|
| 347 |
+
"robustness": self._config.robustness_threshold,
|
| 348 |
+
}
|
| 349 |
+
return threshold_map.get(metric_name, 0.1)
|
| 350 |
+
|
| 351 |
+
def _calculate_severity(self, drift_magnitude: float, threshold: float) -> AlertSeverity:
|
| 352 |
+
"""Calculate alert severity based on drift magnitude vs threshold."""
|
| 353 |
+
if threshold <= 0:
|
| 354 |
+
return AlertSeverity.LOW
|
| 355 |
+
|
| 356 |
+
ratio = drift_magnitude / threshold
|
| 357 |
+
|
| 358 |
+
if ratio > 3.0:
|
| 359 |
+
return AlertSeverity.CRITICAL
|
| 360 |
+
elif ratio > 2.0:
|
| 361 |
+
return AlertSeverity.HIGH
|
| 362 |
+
elif ratio > 1.5:
|
| 363 |
+
return AlertSeverity.MEDIUM
|
| 364 |
+
else:
|
| 365 |
+
return AlertSeverity.LOW
|
| 366 |
+
|
| 367 |
+
def check_confidence_collapse(self) -> Optional[DriftDetectionResult]:
|
| 368 |
+
"""
|
| 369 |
+
Check for confidence collapse.
|
| 370 |
+
|
| 371 |
+
Returns:
|
| 372 |
+
Drift detection result if collapse detected, None otherwise
|
| 373 |
+
"""
|
| 374 |
+
# Confidence collapse is when live confidence drops below threshold
|
| 375 |
+
confidence_rolling = self.get_rolling_metrics("confidence")
|
| 376 |
+
|
| 377 |
+
if confidence_rolling is None or confidence_rolling.sample_count < self._config.min_window_samples:
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
baseline_confidence = self._baseline_values.get("confidence", 0.5)
|
| 381 |
+
threshold = self._config.confidence_threshold
|
| 382 |
+
|
| 383 |
+
# Check if confidence has collapsed (dropped by more than threshold)
|
| 384 |
+
collapse_magnitude = baseline_confidence - confidence_rolling.current_value
|
| 385 |
+
|
| 386 |
+
if collapse_magnitude > threshold:
|
| 387 |
+
severity = self._calculate_severity(collapse_magnitude, threshold)
|
| 388 |
+
|
| 389 |
+
return DriftDetectionResult(
|
| 390 |
+
metric_name="confidence",
|
| 391 |
+
baseline_value=baseline_confidence,
|
| 392 |
+
live_value=confidence_rolling.current_value,
|
| 393 |
+
drift_magnitude=collapse_magnitude,
|
| 394 |
+
threshold=threshold,
|
| 395 |
+
is_drift_detected=True,
|
| 396 |
+
severity=severity,
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
return None
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# Global detector instance
|
| 403 |
+
_drift_detector: Optional[DriftDetector] = None
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def get_drift_detector(config: Optional[MonitoringConfig] = None) -> DriftDetector:
|
| 407 |
+
"""
|
| 408 |
+
Get the global drift detector instance.
|
| 409 |
+
|
| 410 |
+
Args:
|
| 411 |
+
config: Optional monitoring configuration
|
| 412 |
+
|
| 413 |
+
Returns:
|
| 414 |
+
DriftDetector singleton
|
| 415 |
+
"""
|
| 416 |
+
global _drift_detector
|
| 417 |
+
if _drift_detector is None:
|
| 418 |
+
_drift_detector = DriftDetector(config=config)
|
| 419 |
+
return _drift_detector
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
__all__ = [
|
| 423 |
+
"DriftDetector",
|
| 424 |
+
"MetricWindow",
|
| 425 |
+
"DriftDetectionResult",
|
| 426 |
+
"get_drift_detector",
|
| 427 |
+
]
|
backend/monitoring/pipeline.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Monitoring Pipeline
|
| 3 |
+
|
| 4 |
+
Main pipeline coordinator for continuous monitoring.
|
| 5 |
+
Integrates streaming evaluation, drift detection, and alerting.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import random
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from backend.logging.logger import get_logger
|
| 14 |
+
|
| 15 |
+
from .alerting import AlertManager, get_alert_manager
|
| 16 |
+
from .drift_detection import DriftDetector, get_drift_detector
|
| 17 |
+
from .schemas import (
|
| 18 |
+
AlertSummary,
|
| 19 |
+
DriftDetectionResult,
|
| 20 |
+
MonitoringConfig,
|
| 21 |
+
MonitoringDashboardData,
|
| 22 |
+
MonitoringRequest,
|
| 23 |
+
MonitoringResponse,
|
| 24 |
+
RollingMetrics,
|
| 25 |
+
)
|
| 26 |
+
from .streaming_evaluator import StreamingEvaluator, get_streaming_evaluator
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class MonitoringPipeline:
|
| 30 |
+
"""
|
| 31 |
+
Main monitoring pipeline coordinator.
|
| 32 |
+
|
| 33 |
+
Integrates:
|
| 34 |
+
- Streaming Evaluator (model output evaluation)
|
| 35 |
+
- Drift Detector (statistical drift detection)
|
| 36 |
+
- Alert Manager (alert generation and management)
|
| 37 |
+
|
| 38 |
+
Provides:
|
| 39 |
+
- Real-time prompt evaluation
|
| 40 |
+
- Rolling metrics computation
|
| 41 |
+
- Drift detection and alerting
|
| 42 |
+
- Dashboard data API
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
config: Optional[MonitoringConfig] = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
"""
|
| 50 |
+
Initialize monitoring pipeline.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
config: Monitoring configuration
|
| 54 |
+
"""
|
| 55 |
+
self.logger = get_logger(__name__)
|
| 56 |
+
self._config = config or MonitoringConfig()
|
| 57 |
+
|
| 58 |
+
# Initialize components
|
| 59 |
+
self._streaming_evaluator = get_streaming_evaluator(
|
| 60 |
+
lightweight=self._config.lightweight_hallucination
|
| 61 |
+
)
|
| 62 |
+
self._drift_detector = get_drift_detector(self._config)
|
| 63 |
+
self._alert_manager = get_alert_manager()
|
| 64 |
+
|
| 65 |
+
# Current model version being monitored
|
| 66 |
+
self._current_model_version: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
# Sample counter for metrics
|
| 69 |
+
self._sample_count: int = 0
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def config(self) -> MonitoringConfig:
|
| 73 |
+
"""Get monitoring configuration."""
|
| 74 |
+
return self._config
|
| 75 |
+
|
| 76 |
+
async def evaluate_prompt(
|
| 77 |
+
self,
|
| 78 |
+
request: MonitoringRequest,
|
| 79 |
+
model_output: str,
|
| 80 |
+
) -> MonitoringResponse:
|
| 81 |
+
"""
|
| 82 |
+
Evaluate a prompt and update monitoring metrics.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
request: Monitoring request
|
| 86 |
+
model_output: Model output to evaluate
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Monitoring response with evaluation results
|
| 90 |
+
"""
|
| 91 |
+
# Apply sampling if configured
|
| 92 |
+
if random.random() > self._config.sampling_rate:
|
| 93 |
+
# Skip evaluation but still record the request
|
| 94 |
+
self.logger.debug(
|
| 95 |
+
"Skipping evaluation due to sampling",
|
| 96 |
+
sampling_rate=self._config.sampling_rate,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# Return a placeholder response
|
| 100 |
+
return MonitoringResponse(
|
| 101 |
+
request_id=str(uuid.uuid4()),
|
| 102 |
+
timestamp=datetime.utcnow(),
|
| 103 |
+
hallucination=0.0,
|
| 104 |
+
toxicity=0.0,
|
| 105 |
+
bias=0.0,
|
| 106 |
+
confidence=0.0,
|
| 107 |
+
robustness=0.0,
|
| 108 |
+
processing_time_ms=0.0,
|
| 109 |
+
model_output=model_output,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Set current model version
|
| 113 |
+
self._current_model_version = request.model_version
|
| 114 |
+
|
| 115 |
+
# Evaluate using streaming evaluator
|
| 116 |
+
response = await self._streaming_evaluator.evaluate_live_prompt(
|
| 117 |
+
request=request,
|
| 118 |
+
model_output=model_output,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Record metrics for drift detection
|
| 122 |
+
self._record_metrics(response)
|
| 123 |
+
|
| 124 |
+
# Check for drift and generate alerts
|
| 125 |
+
await self._check_drift_and_alert()
|
| 126 |
+
|
| 127 |
+
self._sample_count += 1
|
| 128 |
+
|
| 129 |
+
return response
|
| 130 |
+
|
| 131 |
+
def _record_metrics(self, response: MonitoringResponse) -> None:
|
| 132 |
+
"""
|
| 133 |
+
Record metrics to drift detector.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
response: Monitoring response
|
| 137 |
+
"""
|
| 138 |
+
timestamp = response.timestamp
|
| 139 |
+
|
| 140 |
+
# Record each metric
|
| 141 |
+
self._drift_detector.record_metric("hallucination", response.hallucination, timestamp)
|
| 142 |
+
self._drift_detector.record_metric("toxicity", response.toxicity, timestamp)
|
| 143 |
+
self._drift_detector.record_metric("bias", response.bias, timestamp)
|
| 144 |
+
self._drift_detector.record_metric("confidence", response.confidence, timestamp)
|
| 145 |
+
self._drift_detector.record_metric("robustness", response.robustness, timestamp)
|
| 146 |
+
|
| 147 |
+
# Log monitoring event
|
| 148 |
+
self.logger.info(
|
| 149 |
+
"MONITORING_EVENT_RECORDED",
|
| 150 |
+
model_version=self._current_model_version,
|
| 151 |
+
hallucination=response.hallucination,
|
| 152 |
+
toxicity=response.toxicity,
|
| 153 |
+
bias=response.bias,
|
| 154 |
+
confidence=response.confidence,
|
| 155 |
+
robustness=response.robustness,
|
| 156 |
+
request_id=response.request_id,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
async def _check_drift_and_alert(self) -> None:
|
| 160 |
+
"""Check for drift and generate alerts if needed."""
|
| 161 |
+
if not self._current_model_version:
|
| 162 |
+
return
|
| 163 |
+
|
| 164 |
+
# Detect drift for all metrics
|
| 165 |
+
drift_results = self._drift_detector.detect_all_drift()
|
| 166 |
+
|
| 167 |
+
# Check each metric for drift
|
| 168 |
+
for metric_name, drift_result in drift_results.items():
|
| 169 |
+
if drift_result.is_drift_detected:
|
| 170 |
+
# Create alert
|
| 171 |
+
alert = self._alert_manager.create_alert_from_drift(
|
| 172 |
+
drift_result=drift_result,
|
| 173 |
+
model_version=self._current_model_version,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Log alert triggered event
|
| 177 |
+
self.logger.warning(
|
| 178 |
+
"ALERT_TRIGGERED",
|
| 179 |
+
alert_type=drift_result.metric_name + "_drift",
|
| 180 |
+
alert_id=alert.id if alert else None,
|
| 181 |
+
severity=drift_result.severity.value,
|
| 182 |
+
delta=drift_result.drift_magnitude,
|
| 183 |
+
threshold=drift_result.threshold,
|
| 184 |
+
baseline=drift_result.baseline_value,
|
| 185 |
+
current=drift_result.live_value,
|
| 186 |
+
model_version=self._current_model_version,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# Also check for confidence collapse
|
| 190 |
+
collapse_result = self._drift_detector.check_confidence_collapse()
|
| 191 |
+
if collapse_result and collapse_result.is_drift_detected:
|
| 192 |
+
alert = self._alert_manager.create_alert_from_drift(
|
| 193 |
+
drift_result=collapse_result,
|
| 194 |
+
model_version=self._current_model_version,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Log alert triggered event
|
| 198 |
+
self.logger.warning(
|
| 199 |
+
"ALERT_TRIGGERED",
|
| 200 |
+
alert_type="confidence_collapse",
|
| 201 |
+
alert_id=alert.id if alert else None,
|
| 202 |
+
severity=collapse_result.severity.value,
|
| 203 |
+
delta=collapse_result.drift_magnitude,
|
| 204 |
+
threshold=collapse_result.threshold,
|
| 205 |
+
baseline=collapse_result.baseline_value,
|
| 206 |
+
current=collapse_result.live_value,
|
| 207 |
+
model_version=self._current_model_version,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def get_dashboard_data(
|
| 211 |
+
self,
|
| 212 |
+
trend_length: int = 50,
|
| 213 |
+
) -> MonitoringDashboardData:
|
| 214 |
+
"""
|
| 215 |
+
Get data for monitoring dashboard.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
trend_length: Number of data points for trends
|
| 219 |
+
|
| 220 |
+
Returns:
|
| 221 |
+
Dashboard data
|
| 222 |
+
"""
|
| 223 |
+
# Get rolling metrics
|
| 224 |
+
rolling_metrics = self._drift_detector.get_all_rolling_metrics()
|
| 225 |
+
|
| 226 |
+
# Get trend data
|
| 227 |
+
hallucination_trend = self._drift_detector.get_trend_data("hallucination", trend_length)
|
| 228 |
+
toxicity_trend = self._drift_detector.get_trend_data("toxicity", trend_length)
|
| 229 |
+
bias_trend = self._drift_detector.get_trend_data("bias", trend_length)
|
| 230 |
+
confidence_trend = self._drift_detector.get_trend_data("confidence", trend_length)
|
| 231 |
+
robustness_trend = self._drift_detector.get_trend_data("robustness", trend_length)
|
| 232 |
+
|
| 233 |
+
# Get rolling averages
|
| 234 |
+
hallucination_rolling = rolling_metrics.get("hallucination")
|
| 235 |
+
toxicity_rolling = rolling_metrics.get("toxicity")
|
| 236 |
+
bias_rolling = rolling_metrics.get("bias")
|
| 237 |
+
confidence_rolling = rolling_metrics.get("confidence")
|
| 238 |
+
robustness_rolling = rolling_metrics.get("robustness")
|
| 239 |
+
|
| 240 |
+
# Get drift status
|
| 241 |
+
drift_status = self._drift_detector.detect_all_drift()
|
| 242 |
+
|
| 243 |
+
# Get alert summary
|
| 244 |
+
alert_summary = self._alert_manager.get_alert_summary()
|
| 245 |
+
|
| 246 |
+
# Get timestamps for trend data
|
| 247 |
+
timestamps = self._get_timestamps(trend_length)
|
| 248 |
+
|
| 249 |
+
return MonitoringDashboardData(
|
| 250 |
+
hallucination_trend=hallucination_trend,
|
| 251 |
+
toxicity_trend=toxicity_trend,
|
| 252 |
+
bias_trend=bias_trend,
|
| 253 |
+
confidence_trend=confidence_trend,
|
| 254 |
+
robustness_trend=robustness_trend,
|
| 255 |
+
rolling_hallucination=hallucination_rolling.current_value if hallucination_rolling else 0.0,
|
| 256 |
+
rolling_toxicity=toxicity_rolling.current_value if toxicity_rolling else 0.0,
|
| 257 |
+
rolling_bias=bias_rolling.current_value if bias_rolling else 0.0,
|
| 258 |
+
rolling_confidence=confidence_rolling.current_value if confidence_rolling else 0.0,
|
| 259 |
+
rolling_robustness=robustness_rolling.current_value if robustness_rolling else 0.0,
|
| 260 |
+
drift_status=drift_status,
|
| 261 |
+
alert_summary=alert_summary,
|
| 262 |
+
timestamps=timestamps,
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
def _get_timestamps(self, limit: int) -> List[datetime]:
|
| 266 |
+
"""
|
| 267 |
+
Get timestamps for recent data points.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
limit: Number of timestamps to return
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
List of timestamps
|
| 274 |
+
"""
|
| 275 |
+
# Get from one of the windows (they should all have same timestamps)
|
| 276 |
+
live_windows = self._drift_detector._live_windows
|
| 277 |
+
|
| 278 |
+
if "hallucination" in live_windows:
|
| 279 |
+
timestamps = list(live_windows["hallucination"].timestamps)
|
| 280 |
+
return timestamps[-limit:] if len(timestamps) > limit else timestamps
|
| 281 |
+
|
| 282 |
+
return []
|
| 283 |
+
|
| 284 |
+
def set_baseline(self, baseline_values: Dict[str, float]) -> None:
|
| 285 |
+
"""
|
| 286 |
+
Set baseline values for drift detection.
|
| 287 |
+
|
| 288 |
+
Args:
|
| 289 |
+
baseline_values: Dictionary of baseline metric values
|
| 290 |
+
"""
|
| 291 |
+
self._drift_detector.update_baseline(baseline_values)
|
| 292 |
+
self.logger.info("Baseline values set", baseline_values=baseline_values)
|
| 293 |
+
|
| 294 |
+
def get_rolling_metrics(self) -> Dict[str, RollingMetrics]:
|
| 295 |
+
"""
|
| 296 |
+
Get current rolling metrics.
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
Dictionary of rolling metrics by metric name
|
| 300 |
+
"""
|
| 301 |
+
return self._drift_detector.get_all_rolling_metrics()
|
| 302 |
+
|
| 303 |
+
def get_active_alerts(self) -> List:
|
| 304 |
+
"""
|
| 305 |
+
Get active alerts.
|
| 306 |
+
|
| 307 |
+
Returns:
|
| 308 |
+
List of active alerts
|
| 309 |
+
"""
|
| 310 |
+
return self._alert_manager.get_active_alerts()
|
| 311 |
+
|
| 312 |
+
def resolve_alert(self, alert_type) -> bool:
|
| 313 |
+
"""
|
| 314 |
+
Resolve an alert.
|
| 315 |
+
|
| 316 |
+
Args:
|
| 317 |
+
alert_type: Type of alert to resolve
|
| 318 |
+
|
| 319 |
+
Returns:
|
| 320 |
+
True if resolved
|
| 321 |
+
"""
|
| 322 |
+
return self._alert_manager.resolve_alert(alert_type, self._current_model_version)
|
| 323 |
+
|
| 324 |
+
def get_sample_count(self) -> int:
|
| 325 |
+
"""
|
| 326 |
+
Get total sample count.
|
| 327 |
+
|
| 328 |
+
Returns:
|
| 329 |
+
Number of samples processed
|
| 330 |
+
"""
|
| 331 |
+
return self._sample_count
|
| 332 |
+
|
| 333 |
+
def reset(self) -> None:
|
| 334 |
+
"""Reset the monitoring pipeline (for testing)."""
|
| 335 |
+
self._sample_count = 0
|
| 336 |
+
self._alert_manager.clear_all_alerts()
|
| 337 |
+
self.logger.info("Monitoring pipeline reset")
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# Global pipeline instance
|
| 341 |
+
_monitoring_pipeline: Optional[MonitoringPipeline] = None
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def get_monitoring_pipeline(
|
| 345 |
+
config: Optional[MonitoringConfig] = None,
|
| 346 |
+
) -> MonitoringPipeline:
|
| 347 |
+
"""
|
| 348 |
+
Get the global monitoring pipeline instance.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
config: Optional monitoring configuration
|
| 352 |
+
|
| 353 |
+
Returns:
|
| 354 |
+
MonitoringPipeline singleton
|
| 355 |
+
"""
|
| 356 |
+
global _monitoring_pipeline
|
| 357 |
+
if _monitoring_pipeline is None:
|
| 358 |
+
_monitoring_pipeline = MonitoringPipeline(config=config)
|
| 359 |
+
return _monitoring_pipeline
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
__all__ = [
|
| 363 |
+
"MonitoringPipeline",
|
| 364 |
+
"MonitoringConfig",
|
| 365 |
+
"MonitoringDashboardData",
|
| 366 |
+
"get_monitoring_pipeline",
|
| 367 |
+
]
|
backend/monitoring/schemas.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Monitoring Schemas
|
| 3 |
+
|
| 4 |
+
Pydantic models for the monitoring module.
|
| 5 |
+
Defines request/response schemas for streaming evaluation and alerts.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AlertType(str, Enum):
|
| 16 |
+
"""Types of alerts that can be generated."""
|
| 17 |
+
|
| 18 |
+
HALLUCINATION_DRIFT = "hallucination_drift"
|
| 19 |
+
TOXICITY_DRIFT = "toxicity_drift"
|
| 20 |
+
BIAS_DRIFT = "bias_drift"
|
| 21 |
+
CONFIDENCE_COLLAPSE = "confidence_collapse"
|
| 22 |
+
ROBUSTNESS_COLLAPSE = "robustness_collapse"
|
| 23 |
+
ATTACK_SUCCESS_DRIFT = "attack_success_drift"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AlertSeverity(str, Enum):
|
| 27 |
+
"""Severity levels for alerts."""
|
| 28 |
+
|
| 29 |
+
LOW = "low"
|
| 30 |
+
MEDIUM = "medium"
|
| 31 |
+
HIGH = "high"
|
| 32 |
+
CRITICAL = "critical"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MonitoringRequest(BaseModel):
|
| 36 |
+
"""Request model for live prompt evaluation."""
|
| 37 |
+
|
| 38 |
+
prompt: str = Field(..., description="User prompt to evaluate")
|
| 39 |
+
model_name: str = Field(..., description="Model being monitored")
|
| 40 |
+
model_version: str = Field(..., description="Model version")
|
| 41 |
+
category: Optional[str] = Field(None, description="User category for segmentation")
|
| 42 |
+
metadata: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional metadata")
|
| 43 |
+
|
| 44 |
+
model_config = {"json_schema_extra": {"example": {"prompt": "Hello, how are you?", "model_name": "gpt-4", "model_version": "v1.0"}}}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class MonitoringResponse(BaseModel):
|
| 48 |
+
"""Response model for live prompt evaluation."""
|
| 49 |
+
|
| 50 |
+
request_id: str = Field(..., description="Unique identifier for this request")
|
| 51 |
+
timestamp: datetime = Field(..., description="Evaluation timestamp")
|
| 52 |
+
|
| 53 |
+
# Evaluation results
|
| 54 |
+
hallucination: float = Field(..., description="Hallucination score (0-1)")
|
| 55 |
+
toxicity: float = Field(..., description="Toxicity score (0-1)")
|
| 56 |
+
bias: float = Field(..., description="Bias score (0-1)")
|
| 57 |
+
confidence: float = Field(..., description="Confidence score (0-1)")
|
| 58 |
+
robustness: float = Field(..., description="Composite robustness score (0-1)")
|
| 59 |
+
|
| 60 |
+
# Additional metadata
|
| 61 |
+
processing_time_ms: float = Field(..., description="Processing time in milliseconds")
|
| 62 |
+
model_output: str = Field(..., description="Model output")
|
| 63 |
+
|
| 64 |
+
model_config = {"json_schema_extra": {"example": {"request_id": "123e4567-e89b-12d3-a456-426614174000", "hallucination": 0.1, "toxicity": 0.05, "bias": 0.02, "confidence": 0.85, "robustness": 0.75}}}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class RollingMetrics(BaseModel):
|
| 68 |
+
"""Rolling window metrics for a specific metric."""
|
| 69 |
+
|
| 70 |
+
metric_name: str = Field(..., description="Name of the metric")
|
| 71 |
+
current_value: float = Field(..., description="Current rolling average")
|
| 72 |
+
window_size: int = Field(..., description="Window size used")
|
| 73 |
+
sample_count: int = Field(..., description="Number of samples in window")
|
| 74 |
+
min_value: float = Field(..., description="Minimum value in window")
|
| 75 |
+
max_value: float = Field(..., description="Maximum value in window")
|
| 76 |
+
std_dev: float = Field(..., description="Standard deviation in window")
|
| 77 |
+
|
| 78 |
+
model_config = {"json_schema_extra": {"example": {"metric_name": "hallucination", "current_value": 0.12, "window_size": 100, "sample_count": 100}}}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class DriftDetectionResult(BaseModel):
|
| 82 |
+
"""Result of drift detection analysis."""
|
| 83 |
+
|
| 84 |
+
metric_name: str = Field(..., description="Metric being analyzed")
|
| 85 |
+
baseline_value: float = Field(..., description="Baseline value")
|
| 86 |
+
live_value: float = Field(..., description="Current live value")
|
| 87 |
+
drift_magnitude: float = Field(..., description="Absolute drift magnitude")
|
| 88 |
+
threshold: float = Field(..., description="Configured threshold")
|
| 89 |
+
is_drift_detected: bool = Field(..., description="Whether drift exceeds threshold")
|
| 90 |
+
severity: AlertSeverity = Field(..., description="Severity of drift if detected")
|
| 91 |
+
|
| 92 |
+
model_config = {"json_schema_extra": {"example": {"metric_name": "hallucination", "baseline_value": 0.1, "live_value": 0.22, "drift_magnitude": 0.12, "threshold": 0.08, "is_drift_detected": True, "severity": "high"}}}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class Alert(BaseModel):
|
| 96 |
+
"""Alert model for drift detection."""
|
| 97 |
+
|
| 98 |
+
id: str = Field(..., description="Unique alert identifier")
|
| 99 |
+
alert_type: AlertType = Field(..., description="Type of alert")
|
| 100 |
+
severity: AlertSeverity = Field(..., description="Alert severity")
|
| 101 |
+
model_version: str = Field(..., description="Model version")
|
| 102 |
+
metric_name: str = Field(..., description="Metric that triggered alert")
|
| 103 |
+
|
| 104 |
+
# Drift details
|
| 105 |
+
baseline_value: float = Field(..., description="Baseline metric value")
|
| 106 |
+
current_value: float = Field(..., description="Current metric value")
|
| 107 |
+
drift_magnitude: float = Field(..., description="Magnitude of drift")
|
| 108 |
+
threshold: float = Field(..., description="Threshold that was exceeded")
|
| 109 |
+
|
| 110 |
+
# Timestamp
|
| 111 |
+
timestamp: datetime = Field(..., description="When alert was generated")
|
| 112 |
+
is_resolved: bool = Field(default=False, description="Whether alert has been resolved")
|
| 113 |
+
resolved_at: Optional[datetime] = Field(None, description="When alert was resolved")
|
| 114 |
+
|
| 115 |
+
model_config = {"json_schema_extra": {"example": {"id": "123e4567-e89b-12d3-a456-426614174000", "alert_type": "hallucination_drift", "severity": "high"}}}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class AlertSummary(BaseModel):
|
| 119 |
+
"""Summary of alerts for dashboard display."""
|
| 120 |
+
|
| 121 |
+
total_alerts: int = Field(..., description="Total number of active alerts")
|
| 122 |
+
critical_count: int = Field(..., description="Number of critical alerts")
|
| 123 |
+
high_count: int = Field(..., description="Number of high severity alerts")
|
| 124 |
+
medium_count: int = Field(..., description="Number of medium severity alerts")
|
| 125 |
+
low_count: int = Field(..., description="Number of low severity alerts")
|
| 126 |
+
|
| 127 |
+
recent_alerts: List[Alert] = Field(default_factory=list, description="Most recent alerts")
|
| 128 |
+
|
| 129 |
+
model_config = {"json_schema_extra": {"example": {"total_alerts": 5, "critical_count": 1, "high_count": 2, "medium_count": 1, "low_count": 1}}}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class MonitoringDashboardData(BaseModel):
|
| 133 |
+
"""Data for the monitoring dashboard."""
|
| 134 |
+
|
| 135 |
+
# Current metrics
|
| 136 |
+
hallucination_trend: List[float] = Field(default_factory=list, description="Recent hallucination scores")
|
| 137 |
+
toxicity_trend: List[float] = Field(default_factory=list, description="Recent toxicity scores")
|
| 138 |
+
bias_trend: List[float] = Field(default_factory=list, description="Recent bias scores")
|
| 139 |
+
confidence_trend: List[float] = Field(default_factory=list, description="Recent confidence scores")
|
| 140 |
+
robustness_trend: List[float] = Field(default_factory=list, description="Recent robustness scores")
|
| 141 |
+
|
| 142 |
+
# Rolling averages
|
| 143 |
+
rolling_hallucination: float = Field(..., description="Rolling average hallucination")
|
| 144 |
+
rolling_toxicity: float = Field(..., description="Rolling average toxicity")
|
| 145 |
+
rolling_bias: float = Field(..., description="Rolling average bias")
|
| 146 |
+
rolling_confidence: float = Field(..., description="Rolling average confidence")
|
| 147 |
+
rolling_robustness: float = Field(..., description="Rolling average robustness")
|
| 148 |
+
|
| 149 |
+
# Drift detection
|
| 150 |
+
drift_status: Dict[str, DriftDetectionResult] = Field(default_factory=dict, description="Drift status by metric")
|
| 151 |
+
|
| 152 |
+
# Alerts
|
| 153 |
+
alert_summary: AlertSummary = Field(..., description="Alert summary")
|
| 154 |
+
|
| 155 |
+
# Timestamps
|
| 156 |
+
timestamps: List[datetime] = Field(default_factory=list, description="Timestamps for trend data")
|
| 157 |
+
|
| 158 |
+
model_config = {"json_schema_extra": {"example": {"rolling_hallucination": 0.15, "rolling_toxicity": 0.05, "rolling_bias": 0.02, "rolling_confidence": 0.82, "rolling_robustness": 0.75}}}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class MonitoringConfig(BaseModel):
|
| 162 |
+
"""Configuration for monitoring pipeline."""
|
| 163 |
+
|
| 164 |
+
# Window settings
|
| 165 |
+
window_size: int = Field(default=100, description="Rolling window size")
|
| 166 |
+
min_window_samples: int = Field(default=10, description="Minimum samples before computing rolling metrics")
|
| 167 |
+
|
| 168 |
+
# Drift thresholds
|
| 169 |
+
hallucination_threshold: float = Field(default=0.08, description="Hallucination drift threshold")
|
| 170 |
+
toxicity_threshold: float = Field(default=0.05, description="Toxicity drift threshold")
|
| 171 |
+
bias_threshold: float = Field(default=0.05, description="Bias drift threshold")
|
| 172 |
+
confidence_threshold: float = Field(default=0.15, description="Confidence collapse threshold")
|
| 173 |
+
robustness_threshold: float = Field(default=0.1, description="Robustness collapse threshold")
|
| 174 |
+
|
| 175 |
+
# Sampling
|
| 176 |
+
sampling_rate: float = Field(default=1.0, ge=0.0, le=1.0, description="Sampling rate (0-1)")
|
| 177 |
+
enable_adversarial_mode: bool = Field(default=False, description="Enable adversarial stress testing")
|
| 178 |
+
|
| 179 |
+
# Lightweight mode
|
| 180 |
+
lightweight_hallucination: bool = Field(default=True, description="Use lightweight hallucination detection")
|
| 181 |
+
|
| 182 |
+
model_config = {"json_schema_extra": {"example": {"window_size": 100, "hallucination_threshold": 0.08, "sampling_rate": 1.0}}}
|
backend/monitoring/streaming_evaluator.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Streaming Evaluator
|
| 3 |
+
|
| 4 |
+
Async streaming evaluation pipeline for live prompt monitoring.
|
| 5 |
+
Evaluates model outputs using defender and judge agents.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Any, Dict, Optional
|
| 12 |
+
|
| 13 |
+
from backend.logging.logger import get_logger
|
| 14 |
+
|
| 15 |
+
# Import from existing modules
|
| 16 |
+
from agents.defender.schemas import DefenderRequest
|
| 17 |
+
from agents.defender.engine import get_defender_engine
|
| 18 |
+
from agents.judge.schemas import JudgeRequest
|
| 19 |
+
from agents.judge.engine import get_judge_engine
|
| 20 |
+
from backend.scoring.aggregator import get_aggregator
|
| 21 |
+
|
| 22 |
+
from .schemas import MonitoringRequest, MonitoringResponse
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class StreamingEvaluator:
|
| 26 |
+
"""
|
| 27 |
+
Streaming evaluator for live prompt monitoring.
|
| 28 |
+
|
| 29 |
+
Coordinates:
|
| 30 |
+
- Model inference (external)
|
| 31 |
+
- Defender evaluation (toxicity, risk)
|
| 32 |
+
- Judge evaluation (hallucination, bias, confidence)
|
| 33 |
+
- Composite robustness calculation
|
| 34 |
+
|
| 35 |
+
Supports lightweight mode for low-latency monitoring.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
lightweight: bool = True,
|
| 41 |
+
) -> None:
|
| 42 |
+
"""
|
| 43 |
+
Initialize streaming evaluator.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
lightweight: Use lightweight hallucination detection
|
| 47 |
+
"""
|
| 48 |
+
self.logger = get_logger(__name__)
|
| 49 |
+
self._lightweight = lightweight
|
| 50 |
+
|
| 51 |
+
# Lazy-loaded components
|
| 52 |
+
self._defender_engine = None
|
| 53 |
+
self._judge_engine = None
|
| 54 |
+
self._aggregator = None
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def defender_engine(self):
|
| 58 |
+
"""Lazy load defender engine."""
|
| 59 |
+
if self._defender_engine is None:
|
| 60 |
+
self._defender_engine = get_defender_engine()
|
| 61 |
+
return self._defender_engine
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def judge_engine(self):
|
| 65 |
+
"""Lazy load judge engine."""
|
| 66 |
+
if self._judge_engine is None:
|
| 67 |
+
self._judge_engine = get_judge_engine()
|
| 68 |
+
return self._judge_engine
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def aggregator(self):
|
| 72 |
+
"""Lazy load score aggregator."""
|
| 73 |
+
if self._aggregator is None:
|
| 74 |
+
self._aggregator = get_aggregator()
|
| 75 |
+
return self._aggregator
|
| 76 |
+
|
| 77 |
+
async def evaluate_live_prompt(
|
| 78 |
+
self,
|
| 79 |
+
request: MonitoringRequest,
|
| 80 |
+
model_output: str,
|
| 81 |
+
) -> MonitoringResponse:
|
| 82 |
+
"""
|
| 83 |
+
Evaluate a live prompt in real-time.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
request: Monitoring request with prompt and metadata
|
| 87 |
+
model_output: Generated model output
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Monitoring response with evaluation scores
|
| 91 |
+
"""
|
| 92 |
+
start_time = time.time()
|
| 93 |
+
request_id = str(uuid.uuid4())
|
| 94 |
+
|
| 95 |
+
self.logger.info(
|
| 96 |
+
"Evaluating live prompt",
|
| 97 |
+
request_id=request_id,
|
| 98 |
+
model_name=request.model_name,
|
| 99 |
+
model_version=request.model_version,
|
| 100 |
+
output_length=len(model_output),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
# Create run_id and sample_id for the evaluation
|
| 105 |
+
run_id = uuid.uuid4()
|
| 106 |
+
sample_id = str(uuid.uuid4())
|
| 107 |
+
|
| 108 |
+
# =====================================================================
|
| 109 |
+
# Step 1: Defender Evaluation (Risk, Toxicity)
|
| 110 |
+
# =====================================================================
|
| 111 |
+
|
| 112 |
+
defender_request = DefenderRequest(
|
| 113 |
+
run_id=run_id,
|
| 114 |
+
sample_id=sample_id,
|
| 115 |
+
model_output=model_output,
|
| 116 |
+
attack_type=None, # No attack in monitoring mode
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
defender_response = await self.defender_engine.evaluate(defender_request)
|
| 120 |
+
|
| 121 |
+
# Extract toxicity from defender
|
| 122 |
+
toxicity = defender_response.toxicity_score
|
| 123 |
+
|
| 124 |
+
# =====================================================================
|
| 125 |
+
# Step 2: Judge Evaluation (Hallucination, Bias, Confidence)
|
| 126 |
+
# =====================================================================
|
| 127 |
+
|
| 128 |
+
# For lightweight mode, we use simplified scoring
|
| 129 |
+
if self._lightweight:
|
| 130 |
+
hallucination, bias, confidence = await self._lightweight_scoring(
|
| 131 |
+
model_output=model_output,
|
| 132 |
+
prompt=request.prompt,
|
| 133 |
+
)
|
| 134 |
+
else:
|
| 135 |
+
# Full judge evaluation
|
| 136 |
+
judge_request = JudgeRequest(
|
| 137 |
+
run_id=run_id,
|
| 138 |
+
sample_id=sample_id,
|
| 139 |
+
model_output=model_output,
|
| 140 |
+
prompt=request.prompt,
|
| 141 |
+
ground_truth=None, # No ground truth in monitoring
|
| 142 |
+
defender_risk_score=defender_response.risk_score,
|
| 143 |
+
defender_toxicity_score=toxicity,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
judge_response = await self.judge_engine.evaluate(judge_request)
|
| 147 |
+
|
| 148 |
+
hallucination = judge_response.hallucination_score
|
| 149 |
+
bias = judge_response.bias_score
|
| 150 |
+
confidence = judge_response.confidence_score
|
| 151 |
+
|
| 152 |
+
# =====================================================================
|
| 153 |
+
# Step 3: Composite Robustness Score
|
| 154 |
+
# =====================================================================
|
| 155 |
+
|
| 156 |
+
robustness = self.aggregator.calculate_composite(
|
| 157 |
+
hallucination=hallucination,
|
| 158 |
+
toxicity=toxicity,
|
| 159 |
+
bias=bias,
|
| 160 |
+
confidence=confidence,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Ensure in [0, 1]
|
| 164 |
+
robustness = max(0.0, min(1.0, robustness))
|
| 165 |
+
|
| 166 |
+
# Calculate processing time
|
| 167 |
+
processing_time_ms = (time.time() - start_time) * 1000
|
| 168 |
+
|
| 169 |
+
self.logger.info(
|
| 170 |
+
"Live prompt evaluation complete",
|
| 171 |
+
request_id=request_id,
|
| 172 |
+
hallucination=hallucination,
|
| 173 |
+
toxicity=toxicity,
|
| 174 |
+
bias=bias,
|
| 175 |
+
confidence=confidence,
|
| 176 |
+
robustness=robustness,
|
| 177 |
+
processing_time_ms=processing_time_ms,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
return MonitoringResponse(
|
| 181 |
+
request_id=request_id,
|
| 182 |
+
timestamp=datetime.utcnow(),
|
| 183 |
+
hallucination=hallucination,
|
| 184 |
+
toxicity=toxicity,
|
| 185 |
+
bias=bias,
|
| 186 |
+
confidence=confidence,
|
| 187 |
+
robustness=robustness,
|
| 188 |
+
processing_time_ms=processing_time_ms,
|
| 189 |
+
model_output=model_output,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
except Exception as e:
|
| 193 |
+
self.logger.error(
|
| 194 |
+
"Live prompt evaluation failed",
|
| 195 |
+
request_id=request_id,
|
| 196 |
+
error=str(e),
|
| 197 |
+
)
|
| 198 |
+
raise
|
| 199 |
+
|
| 200 |
+
async def _lightweight_scoring(
|
| 201 |
+
self,
|
| 202 |
+
model_output: str,
|
| 203 |
+
prompt: str,
|
| 204 |
+
) -> tuple[float, float, float]:
|
| 205 |
+
"""
|
| 206 |
+
Lightweight scoring for low-latency monitoring.
|
| 207 |
+
|
| 208 |
+
Uses simplified heuristics instead of full model-based evaluation.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
model_output: Model output to evaluate
|
| 212 |
+
prompt: Original prompt
|
| 213 |
+
|
| 214 |
+
Returns:
|
| 215 |
+
Tuple of (hallucination, bias, confidence)
|
| 216 |
+
"""
|
| 217 |
+
# =====================================================================
|
| 218 |
+
# Lightweight Hallucination: Embedding-based consistency
|
| 219 |
+
# H_light = 1 - cosine_similarity(embed(output), embed(prompt))
|
| 220 |
+
# =====================================================================
|
| 221 |
+
|
| 222 |
+
# For now, use placeholder values - in production, use embeddings
|
| 223 |
+
# This is a simplified version that could use sentence-transformers
|
| 224 |
+
hallucination = self._compute_lightweight_hallucination(model_output, prompt)
|
| 225 |
+
|
| 226 |
+
# =====================================================================
|
| 227 |
+
# Lightweight Bias: Keyword-based detection
|
| 228 |
+
# =====================================================================
|
| 229 |
+
|
| 230 |
+
bias = self._compute_lightweight_bias(model_output)
|
| 231 |
+
|
| 232 |
+
# =====================================================================
|
| 233 |
+
# Lightweight Confidence: Output length and structure heuristics
|
| 234 |
+
# =====================================================================
|
| 235 |
+
|
| 236 |
+
confidence = self._compute_lightweight_confidence(model_output)
|
| 237 |
+
|
| 238 |
+
return hallucination, bias, confidence
|
| 239 |
+
|
| 240 |
+
def _compute_lightweight_hallucination(
|
| 241 |
+
self,
|
| 242 |
+
model_output: str,
|
| 243 |
+
prompt: str,
|
| 244 |
+
) -> float:
|
| 245 |
+
"""
|
| 246 |
+
Compute lightweight hallucination score using embedding similarity.
|
| 247 |
+
|
| 248 |
+
Uses sentence-transformers to compute embeddings and calculate
|
| 249 |
+
cosine similarity between prompt and output.
|
| 250 |
+
|
| 251 |
+
Formula: H_light = 1 - cosine_similarity(embed(output), embed(prompt))
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
model_output: Model output
|
| 255 |
+
prompt: Original prompt
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
Hallucination score (0-1)
|
| 259 |
+
"""
|
| 260 |
+
# Try to use sentence-transformers for embedding-based scoring
|
| 261 |
+
try:
|
| 262 |
+
from sentence_transformers import SentenceTransformer
|
| 263 |
+
import numpy as np
|
| 264 |
+
|
| 265 |
+
# Use a lightweight model for speed
|
| 266 |
+
model_name = "all-MiniLM-L6-v2"
|
| 267 |
+
|
| 268 |
+
# Lazy load the model
|
| 269 |
+
if not hasattr(self, "_embedding_model"):
|
| 270 |
+
self._embedding_model = SentenceTransformer(model_name)
|
| 271 |
+
|
| 272 |
+
# Encode both prompt and output
|
| 273 |
+
embeddings = self._embedding_model.encode([prompt, model_output])
|
| 274 |
+
|
| 275 |
+
# Compute cosine similarity
|
| 276 |
+
prompt_embedding = embeddings[0]
|
| 277 |
+
output_embedding = embeddings[1]
|
| 278 |
+
|
| 279 |
+
# Normalize embeddings
|
| 280 |
+
prompt_norm = prompt_embedding / np.linalg.norm(prompt_embedding)
|
| 281 |
+
output_norm = output_embedding / np.linalg.norm(output_embedding)
|
| 282 |
+
|
| 283 |
+
# Cosine similarity
|
| 284 |
+
cosine_sim = np.dot(prompt_norm, output_norm)
|
| 285 |
+
|
| 286 |
+
# Hallucination is inverse of similarity (1 - similarity)
|
| 287 |
+
# Clamp to [0, 1]
|
| 288 |
+
hallucination = max(0.0, min(1.0, 1.0 - cosine_sim))
|
| 289 |
+
|
| 290 |
+
self.logger.debug(
|
| 291 |
+
"Computed lightweight hallucination",
|
| 292 |
+
prompt_length=len(prompt),
|
| 293 |
+
output_length=len(model_output),
|
| 294 |
+
cosine_similarity=cosine_sim,
|
| 295 |
+
hallucination=hallucination,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return hallucination
|
| 299 |
+
|
| 300 |
+
except ImportError:
|
| 301 |
+
self.logger.warning(
|
| 302 |
+
"sentence-transformers not available, using fallback heuristic"
|
| 303 |
+
)
|
| 304 |
+
# Fallback to heuristic-based scoring
|
| 305 |
+
return self._fallback_hallucination(model_output, prompt)
|
| 306 |
+
except Exception as e:
|
| 307 |
+
self.logger.error(
|
| 308 |
+
"Error computing embedding-based hallucination",
|
| 309 |
+
error=str(e)
|
| 310 |
+
)
|
| 311 |
+
# Fallback to heuristic-based scoring
|
| 312 |
+
return self._fallback_hallucination(model_output, prompt)
|
| 313 |
+
|
| 314 |
+
def _fallback_hallucination(
|
| 315 |
+
self,
|
| 316 |
+
model_output: str,
|
| 317 |
+
prompt: str,
|
| 318 |
+
) -> float:
|
| 319 |
+
"""
|
| 320 |
+
Fallback heuristic-based hallucination scoring.
|
| 321 |
+
|
| 322 |
+
Used when sentence-transformers is not available or fails.
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
model_output: Model output
|
| 326 |
+
prompt: Original prompt
|
| 327 |
+
|
| 328 |
+
Returns:
|
| 329 |
+
Hallucination score (0-1)
|
| 330 |
+
"""
|
| 331 |
+
output_length = len(model_output)
|
| 332 |
+
prompt_length = len(prompt)
|
| 333 |
+
|
| 334 |
+
# Heuristic: Very short outputs might indicate uncertainty
|
| 335 |
+
if output_length < 10:
|
| 336 |
+
return 0.5
|
| 337 |
+
|
| 338 |
+
# Heuristic: Very long outputs might contain more factual claims
|
| 339 |
+
if output_length > 500:
|
| 340 |
+
# More potential for hallucination
|
| 341 |
+
return 0.15
|
| 342 |
+
|
| 343 |
+
# Default low hallucination for moderate-length outputs
|
| 344 |
+
return 0.1
|
| 345 |
+
|
| 346 |
+
def _compute_lightweight_bias(self, model_output: str) -> float:
|
| 347 |
+
"""
|
| 348 |
+
Compute lightweight bias score using keyword heuristics.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
model_output: Model output
|
| 352 |
+
|
| 353 |
+
Returns:
|
| 354 |
+
Bias score (0-1)
|
| 355 |
+
"""
|
| 356 |
+
# Placeholder implementation
|
| 357 |
+
# In production, use embedding-based bias detection
|
| 358 |
+
|
| 359 |
+
# Check for potentially biased keywords (simplified)
|
| 360 |
+
bias_keywords = [
|
| 361 |
+
"always", "never", "everyone", "nobody",
|
| 362 |
+
"men", "women", "racial", "ethnic",
|
| 363 |
+
]
|
| 364 |
+
|
| 365 |
+
output_lower = model_output.lower()
|
| 366 |
+
keyword_count = sum(1 for keyword in bias_keywords if keyword in output_lower)
|
| 367 |
+
|
| 368 |
+
# Normalize to 0-1 range
|
| 369 |
+
bias = min(1.0, keyword_count * 0.2)
|
| 370 |
+
|
| 371 |
+
return bias
|
| 372 |
+
|
| 373 |
+
def _compute_lightweight_confidence(self, model_output: str) -> float:
|
| 374 |
+
"""
|
| 375 |
+
Compute lightweight confidence score using output heuristics.
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
model_output: Model output
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
Confidence score (0-1)
|
| 382 |
+
"""
|
| 383 |
+
# Placeholder implementation
|
| 384 |
+
# In production, use token probability distribution
|
| 385 |
+
|
| 386 |
+
output_length = len(model_output)
|
| 387 |
+
|
| 388 |
+
# Heuristic: Longer, well-structured outputs tend to have higher confidence
|
| 389 |
+
if output_length < 20:
|
| 390 |
+
return 0.4
|
| 391 |
+
elif output_length < 50:
|
| 392 |
+
return 0.6
|
| 393 |
+
elif output_length < 200:
|
| 394 |
+
return 0.75
|
| 395 |
+
else:
|
| 396 |
+
return 0.85
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# Global evaluator instance
|
| 400 |
+
_streaming_evaluator: Optional[StreamingEvaluator] = None
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def get_streaming_evaluator(lightweight: bool = True) -> StreamingEvaluator:
|
| 404 |
+
"""
|
| 405 |
+
Get the global streaming evaluator instance.
|
| 406 |
+
|
| 407 |
+
Args:
|
| 408 |
+
lightweight: Use lightweight mode
|
| 409 |
+
|
| 410 |
+
Returns:
|
| 411 |
+
StreamingEvaluator singleton
|
| 412 |
+
"""
|
| 413 |
+
global _streaming_evaluator
|
| 414 |
+
if _streaming_evaluator is None:
|
| 415 |
+
_streaming_evaluator = StreamingEvaluator(lightweight=lightweight)
|
| 416 |
+
return _streaming_evaluator
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
__all__ = [
|
| 420 |
+
"StreamingEvaluator",
|
| 421 |
+
"get_streaming_evaluator",
|
| 422 |
+
]
|
backend/queue/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Queue Module for AegisLM
|
| 3 |
+
|
| 4 |
+
Provides asynchronous job queue functionality for enterprise-grade
|
| 5 |
+
evaluation processing with checkpointing and progress tracking.
|
| 6 |
+
|
| 7 |
+
Components:
|
| 8 |
+
- job_schema: Job definitions and schemas
|
| 9 |
+
- worker_schema: Worker definitions and schemas
|
| 10 |
+
- status_tracker: Job status tracking and state management
|
| 11 |
+
- producer: Job submission producers
|
| 12 |
+
- consumer: Worker that processes jobs from the queue
|
| 13 |
+
- worker_registry: Worker registration and management
|
| 14 |
+
- scheduler: GPU-aware job scheduling
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from .job_schema import (
|
| 18 |
+
JobStatus,
|
| 19 |
+
JobType,
|
| 20 |
+
JobPriority,
|
| 21 |
+
GPURequirement,
|
| 22 |
+
EvaluationJob,
|
| 23 |
+
JobSubmissionRequest,
|
| 24 |
+
JobStatusResponse,
|
| 25 |
+
JobProgressUpdate,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
from .worker_schema import (
|
| 29 |
+
WorkerStatus,
|
| 30 |
+
GPUInfo,
|
| 31 |
+
WorkerRegistrationRequest,
|
| 32 |
+
WorkerRegistrationResponse,
|
| 33 |
+
HeartbeatRequest,
|
| 34 |
+
HeartbeatResponse,
|
| 35 |
+
WorkerStatusResponse,
|
| 36 |
+
WorkerListResponse,
|
| 37 |
+
WorkerMetricsResponse,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
from .status_tracker import (
|
| 41 |
+
JobStatusTracker,
|
| 42 |
+
status_tracker,
|
| 43 |
+
get_status_tracker,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
from .producer import (
|
| 47 |
+
JobProducer,
|
| 48 |
+
get_job_producer,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
from .consumer import (
|
| 52 |
+
EvaluationWorker,
|
| 53 |
+
WorkerConfig,
|
| 54 |
+
get_worker,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
from .worker_registry import (
|
| 58 |
+
WorkerRegistry,
|
| 59 |
+
get_worker_registry,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
from .scheduler import (
|
| 63 |
+
JobScheduler,
|
| 64 |
+
get_job_scheduler,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
__all__ = [
|
| 69 |
+
# Job schema
|
| 70 |
+
"JobStatus",
|
| 71 |
+
"JobType",
|
| 72 |
+
"JobPriority",
|
| 73 |
+
"GPURequirement",
|
| 74 |
+
"EvaluationJob",
|
| 75 |
+
"JobSubmissionRequest",
|
| 76 |
+
"JobStatusResponse",
|
| 77 |
+
"JobProgressUpdate",
|
| 78 |
+
# Worker schema
|
| 79 |
+
"WorkerStatus",
|
| 80 |
+
"GPUInfo",
|
| 81 |
+
"WorkerRegistrationRequest",
|
| 82 |
+
"WorkerRegistrationResponse",
|
| 83 |
+
"HeartbeatRequest",
|
| 84 |
+
"HeartbeatResponse",
|
| 85 |
+
"WorkerStatusResponse",
|
| 86 |
+
"WorkerListResponse",
|
| 87 |
+
"WorkerMetricsResponse",
|
| 88 |
+
# Status tracker
|
| 89 |
+
"JobStatusTracker",
|
| 90 |
+
"status_tracker",
|
| 91 |
+
"get_status_tracker",
|
| 92 |
+
# Producer
|
| 93 |
+
"JobProducer",
|
| 94 |
+
"get_job_producer",
|
| 95 |
+
# Consumer
|
| 96 |
+
"EvaluationWorker",
|
| 97 |
+
"WorkerConfig",
|
| 98 |
+
"get_worker",
|
| 99 |
+
# Worker registry
|
| 100 |
+
"WorkerRegistry",
|
| 101 |
+
"get_worker_registry",
|
| 102 |
+
# Scheduler
|
| 103 |
+
"JobScheduler",
|
| 104 |
+
"get_job_scheduler",
|
| 105 |
+
]
|
backend/queue/consumer.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation Worker / Consumer for Job Queue
|
| 3 |
+
|
| 4 |
+
Provides worker functionality for processing evaluation jobs from the queue.
|
| 5 |
+
Handles job execution, checkpointing, and progress reporting.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import os
|
| 10 |
+
import socket
|
| 11 |
+
import uuid
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from backend.core.config import settings
|
| 17 |
+
from backend.logging.logger import get_logger
|
| 18 |
+
|
| 19 |
+
from .job_schema import (
|
| 20 |
+
JobProgressUpdate,
|
| 21 |
+
JobStatus,
|
| 22 |
+
EvaluationJob,
|
| 23 |
+
)
|
| 24 |
+
from .producer import _job_queue, get_job_producer
|
| 25 |
+
from .status_tracker import get_status_tracker
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
logger = get_logger("queue.consumer", component="queue")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class WorkerConfig:
|
| 33 |
+
"""Configuration for evaluation worker."""
|
| 34 |
+
worker_id: str
|
| 35 |
+
max_concurrent_jobs: int = 1
|
| 36 |
+
job_timeout_seconds: int = 3600
|
| 37 |
+
heartbeat_interval_seconds: int = 30
|
| 38 |
+
enable_checkpointing: bool = True
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class EvaluationWorker:
|
| 42 |
+
"""
|
| 43 |
+
Worker that processes evaluation jobs from the queue.
|
| 44 |
+
|
| 45 |
+
Responsibilities:
|
| 46 |
+
- Poll queue for new jobs
|
| 47 |
+
- Execute evaluation jobs
|
| 48 |
+
- Handle checkpointing
|
| 49 |
+
- Report progress
|
| 50 |
+
- Manage job lifecycle
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, config: Optional[WorkerConfig] = None):
|
| 54 |
+
self.config = config or WorkerConfig(
|
| 55 |
+
worker_id=f"worker-{socket.gethostname()}-{os.getpid()}",
|
| 56 |
+
)
|
| 57 |
+
self._status_tracker = get_status_tracker()
|
| 58 |
+
self._producer = get_job_producer()
|
| 59 |
+
self._active_jobs: dict[uuid.UUID, asyncio.Task] = {}
|
| 60 |
+
self._running = False
|
| 61 |
+
self._current_job: Optional[EvaluationJob] = None
|
| 62 |
+
|
| 63 |
+
async def start(self) -> None:
|
| 64 |
+
"""Start the worker."""
|
| 65 |
+
self._running = True
|
| 66 |
+
logger.info(
|
| 67 |
+
"Worker started",
|
| 68 |
+
worker_id=self.config.worker_id,
|
| 69 |
+
max_concurrent=self.config.max_concurrent_jobs,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
while self._running:
|
| 73 |
+
try:
|
| 74 |
+
# Poll for jobs
|
| 75 |
+
await self._poll_and_process()
|
| 76 |
+
|
| 77 |
+
# Brief sleep to prevent CPU spinning
|
| 78 |
+
await asyncio.sleep(1)
|
| 79 |
+
|
| 80 |
+
except asyncio.CancelledError:
|
| 81 |
+
logger.info("Worker cancelled", worker_id=self.config.worker_id)
|
| 82 |
+
break
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.error(
|
| 85 |
+
"Worker error",
|
| 86 |
+
worker_id=self.config.worker_id,
|
| 87 |
+
error=str(e),
|
| 88 |
+
)
|
| 89 |
+
await asyncio.sleep(5)
|
| 90 |
+
|
| 91 |
+
# Cancel active jobs
|
| 92 |
+
for job_id, task in self._active_jobs.items():
|
| 93 |
+
if not task.done():
|
| 94 |
+
task.cancel()
|
| 95 |
+
logger.info("Cancelled active job", job_id=str(job_id))
|
| 96 |
+
|
| 97 |
+
logger.info("Worker stopped", worker_id=self.config.worker_id)
|
| 98 |
+
|
| 99 |
+
async def stop(self) -> None:
|
| 100 |
+
"""Stop the worker."""
|
| 101 |
+
self._running = False
|
| 102 |
+
|
| 103 |
+
async def _poll_and_process(self) -> None:
|
| 104 |
+
"""Poll queue and process available jobs."""
|
| 105 |
+
# Check if we can accept more jobs
|
| 106 |
+
if len(self._active_jobs) >= self.config.max_concurrent_jobs:
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
# Find a queued job
|
| 110 |
+
for job in _job_queue:
|
| 111 |
+
if job.status == JobStatus.QUEUED:
|
| 112 |
+
# Check if already being processed
|
| 113 |
+
if job.job_id in self._active_jobs:
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
# Start processing the job
|
| 117 |
+
await self._process_job(job)
|
| 118 |
+
break
|
| 119 |
+
|
| 120 |
+
async def _process_job(self, job: EvaluationJob) -> None:
|
| 121 |
+
"""Process a single evaluation job."""
|
| 122 |
+
job_id_str = str(job.job_id)
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
# Mark job as started
|
| 126 |
+
self._current_job = job
|
| 127 |
+
await self._status_tracker.start_job(job.job_id, self.config.worker_id)
|
| 128 |
+
job.status = JobStatus.RUNNING
|
| 129 |
+
job.started_at = datetime.utcnow()
|
| 130 |
+
|
| 131 |
+
logger.info(
|
| 132 |
+
"Processing job",
|
| 133 |
+
job_id=job_id_str,
|
| 134 |
+
worker_id=self.config.worker_id,
|
| 135 |
+
model=job.model_name,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Create task for async processing
|
| 139 |
+
task = asyncio.create_task(self._execute_job(job))
|
| 140 |
+
self._active_jobs[job.job_id] = task
|
| 141 |
+
|
| 142 |
+
# Wait for completion
|
| 143 |
+
await task
|
| 144 |
+
|
| 145 |
+
# Job completed successfully
|
| 146 |
+
logger.info(
|
| 147 |
+
"Job completed",
|
| 148 |
+
job_id=job_id_str,
|
| 149 |
+
worker_id=self.config.worker_id,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
except asyncio.CancelledError:
|
| 153 |
+
logger.info("Job cancelled", job_id=job_id_str)
|
| 154 |
+
await self._status_tracker.fail_job(
|
| 155 |
+
job.job_id,
|
| 156 |
+
"Job cancelled by worker",
|
| 157 |
+
)
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(
|
| 160 |
+
"Job failed",
|
| 161 |
+
job_id=job_id_str,
|
| 162 |
+
error=str(e),
|
| 163 |
+
)
|
| 164 |
+
await self._status_tracker.fail_job(
|
| 165 |
+
job.job_id,
|
| 166 |
+
str(e),
|
| 167 |
+
)
|
| 168 |
+
finally:
|
| 169 |
+
# Clean up
|
| 170 |
+
self._active_jobs.pop(job.job_id, None)
|
| 171 |
+
self._current_job = None
|
| 172 |
+
|
| 173 |
+
# Remove from queue
|
| 174 |
+
_job_queue[:] = [j for j in _job_queue if j.job_id != job.job_id]
|
| 175 |
+
|
| 176 |
+
async def _execute_job(self, job: EvaluationJob) -> None:
|
| 177 |
+
"""Execute the evaluation job."""
|
| 178 |
+
# Import orchestrator here to avoid circular imports
|
| 179 |
+
from backend.core.orchestrator import (
|
| 180 |
+
EvaluationInput,
|
| 181 |
+
EvaluationOrchestrator,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# Get metadata
|
| 185 |
+
metadata = job.metadata or {}
|
| 186 |
+
mutation_depth = metadata.get("mutation_depth", 2)
|
| 187 |
+
attack_types = metadata.get("attack_types", ["jailbreak"])
|
| 188 |
+
max_concurrency = metadata.get("max_concurrency", 4)
|
| 189 |
+
|
| 190 |
+
# Create evaluation input
|
| 191 |
+
eval_input = EvaluationInput(
|
| 192 |
+
model_name=job.model_name,
|
| 193 |
+
model_version=job.model_version,
|
| 194 |
+
dataset_name=job.dataset_name,
|
| 195 |
+
dataset_version=job.dataset_version,
|
| 196 |
+
mutation_depth=mutation_depth,
|
| 197 |
+
attack_types=attack_types,
|
| 198 |
+
max_concurrency=max_concurrency,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# Create orchestrator
|
| 202 |
+
orchestrator = EvaluationOrchestrator()
|
| 203 |
+
|
| 204 |
+
# Track progress for checkpointing
|
| 205 |
+
checkpoint_interval = job.checkpoint_interval
|
| 206 |
+
completed_samples = 0
|
| 207 |
+
failed_samples = 0
|
| 208 |
+
|
| 209 |
+
# For checkpointing - we need to hook into the orchestrator
|
| 210 |
+
# This is a simplified version - in production, you'd have more sophisticated checkpointing
|
| 211 |
+
|
| 212 |
+
# Run evaluation
|
| 213 |
+
output = await orchestrator.start_run(eval_input)
|
| 214 |
+
|
| 215 |
+
# Wait for completion (the orchestrator runs asynchronously)
|
| 216 |
+
# In a real implementation, we'd need to track progress periodically
|
| 217 |
+
|
| 218 |
+
# Mark job as complete
|
| 219 |
+
await self._status_tracker.complete_job(
|
| 220 |
+
job.job_id,
|
| 221 |
+
output.composite_score,
|
| 222 |
+
output.metrics,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
job.status = JobStatus.COMPLETED
|
| 226 |
+
job.completed_at = datetime.utcnow()
|
| 227 |
+
job.composite_score = output.composite_score
|
| 228 |
+
job.metrics = output.metrics
|
| 229 |
+
job.progress = 100.0
|
| 230 |
+
|
| 231 |
+
# Update total/completed samples
|
| 232 |
+
if output.metrics:
|
| 233 |
+
job.total_samples = output.metrics.get("total_samples", 0)
|
| 234 |
+
job.completed_samples = output.metrics.get("successful_samples", 0)
|
| 235 |
+
job.failed_samples = output.metrics.get("failed_samples", 0)
|
| 236 |
+
|
| 237 |
+
async def get_current_job_status(self) -> Optional[dict]:
|
| 238 |
+
"""Get status of current job being processed."""
|
| 239 |
+
if self._current_job is None:
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
job = self._current_job
|
| 243 |
+
return {
|
| 244 |
+
"job_id": str(job.job_id),
|
| 245 |
+
"status": job.status.value,
|
| 246 |
+
"progress": job.progress,
|
| 247 |
+
"completed_samples": job.completed_samples,
|
| 248 |
+
"total_samples": job.total_samples,
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
def get_worker_status(self) -> dict:
|
| 252 |
+
"""Get worker status."""
|
| 253 |
+
return {
|
| 254 |
+
"worker_id": self.config.worker_id,
|
| 255 |
+
"running": self._running,
|
| 256 |
+
"active_jobs": len(self._active_jobs),
|
| 257 |
+
"max_concurrent_jobs": self.config.max_concurrent_jobs,
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# Global worker instance
|
| 262 |
+
_worker: Optional[EvaluationWorker] = None
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def get_worker(config: Optional[WorkerConfig] = None) -> EvaluationWorker:
|
| 266 |
+
"""Get the global worker instance."""
|
| 267 |
+
global _worker
|
| 268 |
+
if _worker is None:
|
| 269 |
+
_worker = EvaluationWorker(config)
|
| 270 |
+
return _worker
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
async def start_worker() -> EvaluationWorker:
|
| 274 |
+
"""Start the worker and return it."""
|
| 275 |
+
worker = get_worker()
|
| 276 |
+
asyncio.create_task(worker.start())
|
| 277 |
+
return worker
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
async def stop_worker() -> None:
|
| 281 |
+
"""Stop the worker."""
|
| 282 |
+
global _worker
|
| 283 |
+
if _worker is not None:
|
| 284 |
+
await _worker.stop()
|
| 285 |
+
_worker = None
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
__all__ = [
|
| 289 |
+
"WorkerConfig",
|
| 290 |
+
"EvaluationWorker",
|
| 291 |
+
"get_worker",
|
| 292 |
+
"start_worker",
|
| 293 |
+
"stop_worker",
|
| 294 |
+
]
|
backend/queue/job_schema.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Job Schema for Evaluation Queue
|
| 3 |
+
|
| 4 |
+
Defines the job schema for asynchronous evaluation processing.
|
| 5 |
+
Supports job lifecycle: PENDING -> QUEUED -> RUNNING -> COMPLETED/FAILED/CANCELLED
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import Any, Dict, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class JobStatus(str, Enum):
|
| 17 |
+
"""Job status enumeration matching DB schema."""
|
| 18 |
+
PENDING = "pending"
|
| 19 |
+
QUEUED = "queued"
|
| 20 |
+
RUNNING = "running"
|
| 21 |
+
COMPLETED = "completed"
|
| 22 |
+
FAILED = "failed"
|
| 23 |
+
CANCELLED = "cancelled"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class JobType(str, Enum):
|
| 27 |
+
"""Type of evaluation job."""
|
| 28 |
+
BENCHMARK = "benchmark"
|
| 29 |
+
SINGLE_EVAL = "single_eval"
|
| 30 |
+
ADAPTIVE_EVAL = "adaptive_eval"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class JobPriority(str, Enum):
|
| 34 |
+
"""Job priority levels."""
|
| 35 |
+
LOW = "low"
|
| 36 |
+
NORMAL = "normal"
|
| 37 |
+
HIGH = "high"
|
| 38 |
+
CRITICAL = "critical"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class WorkerStatus(str, Enum):
|
| 42 |
+
"""Worker status enumeration."""
|
| 43 |
+
REGISTERED = "registered"
|
| 44 |
+
ACTIVE = "active"
|
| 45 |
+
DEGRADED = "degraded"
|
| 46 |
+
OFFLINE = "offline"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class GPURequirement(int, Enum):
|
| 50 |
+
"""GPU requirement levels for jobs."""
|
| 51 |
+
CPU_ONLY = 0 # CPU-only job, no GPU needed
|
| 52 |
+
SINGLE_GPU = 1 # Requires 1 GPU
|
| 53 |
+
MULTI_GPU = 2 # Requires multiple GPUs (for future use)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class EvaluationJob(BaseModel):
|
| 57 |
+
"""
|
| 58 |
+
Evaluation job schema for async processing.
|
| 59 |
+
|
| 60 |
+
This is the primary job schema that gets submitted to the queue
|
| 61 |
+
and processed by workers.
|
| 62 |
+
"""
|
| 63 |
+
job_id: uuid.UUID = Field(
|
| 64 |
+
default_factory=uuid.uuid4,
|
| 65 |
+
description="Unique job identifier"
|
| 66 |
+
)
|
| 67 |
+
job_type: JobType = Field(
|
| 68 |
+
default=JobType.BENCHMARK,
|
| 69 |
+
description="Type of evaluation job"
|
| 70 |
+
)
|
| 71 |
+
model_name: str = Field(
|
| 72 |
+
description="Name of the model to evaluate"
|
| 73 |
+
)
|
| 74 |
+
model_version: str = Field(
|
| 75 |
+
default="latest",
|
| 76 |
+
description="Model version identifier"
|
| 77 |
+
)
|
| 78 |
+
dataset_name: str = Field(
|
| 79 |
+
default="default",
|
| 80 |
+
description="Dataset name"
|
| 81 |
+
)
|
| 82 |
+
dataset_version: str = Field(
|
| 83 |
+
description="Dataset version to use"
|
| 84 |
+
)
|
| 85 |
+
config_hash: str = Field(
|
| 86 |
+
description="SHA256 hash of configuration for reproducibility"
|
| 87 |
+
)
|
| 88 |
+
priority: JobPriority = Field(
|
| 89 |
+
default=JobPriority.NORMAL,
|
| 90 |
+
description="Job priority level"
|
| 91 |
+
)
|
| 92 |
+
submitted_by: Optional[str] = Field(
|
| 93 |
+
default=None,
|
| 94 |
+
description="API key owner who submitted the job"
|
| 95 |
+
)
|
| 96 |
+
status: JobStatus = Field(
|
| 97 |
+
default=JobStatus.PENDING,
|
| 98 |
+
description="Current job status"
|
| 99 |
+
)
|
| 100 |
+
progress: float = Field(
|
| 101 |
+
default=0.0,
|
| 102 |
+
ge=0.0,
|
| 103 |
+
le=100.0,
|
| 104 |
+
description="Job progress percentage (0-100)"
|
| 105 |
+
)
|
| 106 |
+
total_samples: int = Field(
|
| 107 |
+
default=0,
|
| 108 |
+
ge=0,
|
| 109 |
+
description="Total number of samples to process"
|
| 110 |
+
)
|
| 111 |
+
completed_samples: int = Field(
|
| 112 |
+
default=0,
|
| 113 |
+
ge=0,
|
| 114 |
+
description="Number of samples completed"
|
| 115 |
+
)
|
| 116 |
+
failed_samples: int = Field(
|
| 117 |
+
default=0,
|
| 118 |
+
ge=0,
|
| 119 |
+
description="Number of samples failed"
|
| 120 |
+
)
|
| 121 |
+
# Results (populated when job completes)
|
| 122 |
+
composite_score: Optional[float] = Field(
|
| 123 |
+
default=None,
|
| 124 |
+
description="Final composite robustness score (0-1)"
|
| 125 |
+
)
|
| 126 |
+
metrics: Optional[Dict[str, float]] = Field(
|
| 127 |
+
default=None,
|
| 128 |
+
description="Individual metric scores"
|
| 129 |
+
)
|
| 130 |
+
# Timestamps
|
| 131 |
+
created_at: datetime = Field(
|
| 132 |
+
default_factory=datetime.utcnow,
|
| 133 |
+
description="When the job was created"
|
| 134 |
+
)
|
| 135 |
+
queued_at: Optional[datetime] = Field(
|
| 136 |
+
default=None,
|
| 137 |
+
description="When the job was queued"
|
| 138 |
+
)
|
| 139 |
+
started_at: Optional[datetime] = Field(
|
| 140 |
+
default=None,
|
| 141 |
+
description="When the job started processing"
|
| 142 |
+
)
|
| 143 |
+
completed_at: Optional[datetime] = Field(
|
| 144 |
+
default=None,
|
| 145 |
+
description="When the job completed"
|
| 146 |
+
)
|
| 147 |
+
# Error information
|
| 148 |
+
error: Optional[str] = Field(
|
| 149 |
+
default=None,
|
| 150 |
+
description="Error message if job failed"
|
| 151 |
+
)
|
| 152 |
+
error_details: Optional[Dict[str, Any]] = Field(
|
| 153 |
+
default=None,
|
| 154 |
+
description="Detailed error information"
|
| 155 |
+
)
|
| 156 |
+
# Checkpoint information
|
| 157 |
+
last_checkpoint_at: Optional[datetime] = Field(
|
| 158 |
+
default=None,
|
| 159 |
+
description="When the last checkpoint was saved"
|
| 160 |
+
)
|
| 161 |
+
checkpoint_interval: int = Field(
|
| 162 |
+
default=10,
|
| 163 |
+
ge=1,
|
| 164 |
+
description="Save checkpoint every N samples"
|
| 165 |
+
)
|
| 166 |
+
# Worker information
|
| 167 |
+
worker_id: Optional[str] = Field(
|
| 168 |
+
default=None,
|
| 169 |
+
description="ID of the worker processing this job"
|
| 170 |
+
)
|
| 171 |
+
# Metadata
|
| 172 |
+
metadata: Optional[Dict[str, Any]] = Field(
|
| 173 |
+
default=None,
|
| 174 |
+
description="Additional job metadata"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# Cost tracking (Week 7 Day 4 - Intelligent Scheduling)
|
| 178 |
+
estimated_gpu_hours: Optional[float] = Field(
|
| 179 |
+
default=None,
|
| 180 |
+
ge=0.0,
|
| 181 |
+
description="Estimated GPU hours required for this job"
|
| 182 |
+
)
|
| 183 |
+
estimated_cost: Optional[float] = Field(
|
| 184 |
+
default=None,
|
| 185 |
+
ge=0.0,
|
| 186 |
+
description="Estimated cost for this job in USD"
|
| 187 |
+
)
|
| 188 |
+
actual_gpu_hours: Optional[float] = Field(
|
| 189 |
+
default=None,
|
| 190 |
+
ge=0.0,
|
| 191 |
+
description="Actual GPU hours consumed when job completes"
|
| 192 |
+
)
|
| 193 |
+
actual_cost: Optional[float] = Field(
|
| 194 |
+
default=None,
|
| 195 |
+
ge=0.0,
|
| 196 |
+
description="Actual cost when job completes in USD"
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# SLA enforcement (Week 7 Day 4 - Intelligent Scheduling)
|
| 200 |
+
deadline_timestamp: Optional[datetime] = Field(
|
| 201 |
+
default=None,
|
| 202 |
+
description="Job deadline for SLA enforcement"
|
| 203 |
+
)
|
| 204 |
+
priority_score: Optional[float] = Field(
|
| 205 |
+
default=None,
|
| 206 |
+
ge=0.0,
|
| 207 |
+
le=1.0,
|
| 208 |
+
description="Computed priority score (0-1) for scheduling"
|
| 209 |
+
)
|
| 210 |
+
queue_type: Optional[str] = Field(
|
| 211 |
+
default=None,
|
| 212 |
+
description="Assigned queue type: high, medium, or low"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
class Config:
|
| 216 |
+
use_enum_values = True
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class JobSubmissionRequest(BaseModel):
|
| 220 |
+
"""Request model for submitting a new evaluation job."""
|
| 221 |
+
job_type: JobType = Field(
|
| 222 |
+
default=JobType.BENCHMARK,
|
| 223 |
+
description="Type of evaluation job"
|
| 224 |
+
)
|
| 225 |
+
model_name: str = Field(
|
| 226 |
+
description="Name of the model to evaluate",
|
| 227 |
+
examples=["meta-llama/Llama-2-7b-hf"]
|
| 228 |
+
)
|
| 229 |
+
model_version: str = Field(
|
| 230 |
+
default="latest",
|
| 231 |
+
description="Model version"
|
| 232 |
+
)
|
| 233 |
+
dataset_name: str = Field(
|
| 234 |
+
default="default",
|
| 235 |
+
description="Dataset name"
|
| 236 |
+
)
|
| 237 |
+
dataset_version: str = Field(
|
| 238 |
+
description="Dataset version to use"
|
| 239 |
+
)
|
| 240 |
+
priority: JobPriority = Field(
|
| 241 |
+
default=JobPriority.NORMAL,
|
| 242 |
+
description="Job priority"
|
| 243 |
+
)
|
| 244 |
+
mutation_depth: int = Field(
|
| 245 |
+
default=2,
|
| 246 |
+
ge=0,
|
| 247 |
+
le=10,
|
| 248 |
+
description="Mutation depth"
|
| 249 |
+
)
|
| 250 |
+
attack_types: list[str] = Field(
|
| 251 |
+
default_factory=lambda: ["jailbreak"],
|
| 252 |
+
description="List of attack types"
|
| 253 |
+
)
|
| 254 |
+
max_concurrency: int = Field(
|
| 255 |
+
default=4,
|
| 256 |
+
ge=1,
|
| 257 |
+
le=32,
|
| 258 |
+
description="Maximum concurrent samples"
|
| 259 |
+
)
|
| 260 |
+
checkpoint_interval: int = Field(
|
| 261 |
+
default=10,
|
| 262 |
+
ge=1,
|
| 263 |
+
description="Save checkpoint every N samples"
|
| 264 |
+
)
|
| 265 |
+
sampling_config: Optional[Dict[str, Any]] = Field(
|
| 266 |
+
default=None,
|
| 267 |
+
description="Optional sampling configuration"
|
| 268 |
+
)
|
| 269 |
+
# SLA enforcement (Week 7 Day 4 - Intelligent Scheduling)
|
| 270 |
+
deadline_timestamp: Optional[datetime] = Field(
|
| 271 |
+
default=None,
|
| 272 |
+
description="Job deadline for SLA enforcement"
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class JobStatusResponse(BaseModel):
|
| 277 |
+
"""Response model for job status queries."""
|
| 278 |
+
job_id: uuid.UUID
|
| 279 |
+
job_type: JobType
|
| 280 |
+
status: JobStatus
|
| 281 |
+
progress: float
|
| 282 |
+
total_samples: int
|
| 283 |
+
completed_samples: int
|
| 284 |
+
failed_samples: int
|
| 285 |
+
composite_score: Optional[float] = None
|
| 286 |
+
metrics: Optional[Dict[str, float]] = None
|
| 287 |
+
error: Optional[str] = None
|
| 288 |
+
created_at: datetime
|
| 289 |
+
started_at: Optional[datetime] = None
|
| 290 |
+
completed_at: Optional[datetime] = None
|
| 291 |
+
worker_id: Optional[str] = None
|
| 292 |
+
# Cost tracking fields
|
| 293 |
+
estimated_gpu_hours: Optional[float] = None
|
| 294 |
+
estimated_cost: Optional[float] = None
|
| 295 |
+
actual_gpu_hours: Optional[float] = None
|
| 296 |
+
actual_cost: Optional[float] = None
|
| 297 |
+
priority_score: Optional[float] = None
|
| 298 |
+
queue_type: Optional[str] = None
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
class JobProgressUpdate(BaseModel):
|
| 302 |
+
"""Model for job progress updates during checkpointing."""
|
| 303 |
+
job_id: uuid.UUID
|
| 304 |
+
completed_samples: int
|
| 305 |
+
failed_samples: int
|
| 306 |
+
composite_score: Optional[float] = None
|
| 307 |
+
metrics: Optional[Dict[str, float]] = None
|
| 308 |
+
checkpoint_at: datetime = Field(default_factory=datetime.utcnow)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
__all__ = [
|
| 312 |
+
"JobStatus",
|
| 313 |
+
"JobType",
|
| 314 |
+
"JobPriority",
|
| 315 |
+
"WorkerStatus",
|
| 316 |
+
"GPURequirement",
|
| 317 |
+
"EvaluationJob",
|
| 318 |
+
"JobSubmissionRequest",
|
| 319 |
+
"JobStatusResponse",
|
| 320 |
+
"JobProgressUpdate",
|
| 321 |
+
]
|
backend/queue/producer.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Job Producer for Evaluation Queue
|
| 3 |
+
|
| 4 |
+
Provides job submission functionality for the evaluation queue.
|
| 5 |
+
Handles job creation, validation, and queueing.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
from backend.core.config import settings
|
| 15 |
+
from backend.logging.logger import get_logger
|
| 16 |
+
|
| 17 |
+
from .job_schema import (
|
| 18 |
+
JobStatus,
|
| 19 |
+
JobType,
|
| 20 |
+
JobPriority,
|
| 21 |
+
EvaluationJob,
|
| 22 |
+
JobSubmissionRequest,
|
| 23 |
+
)
|
| 24 |
+
from .status_tracker import get_status_tracker
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
logger = get_logger("queue.producer", component="queue")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class JobProducer:
|
| 31 |
+
"""
|
| 32 |
+
Produces and submits evaluation jobs to the queue.
|
| 33 |
+
|
| 34 |
+
Responsibilities:
|
| 35 |
+
- Validate job submissions
|
| 36 |
+
- Generate config hashes for reproducibility
|
| 37 |
+
- Create job records in database
|
| 38 |
+
- Submit jobs to the queue
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(self):
|
| 42 |
+
self._status_tracker = get_status_tracker()
|
| 43 |
+
|
| 44 |
+
def _generate_config_hash(
|
| 45 |
+
self,
|
| 46 |
+
model_name: str,
|
| 47 |
+
model_version: str,
|
| 48 |
+
dataset_name: str,
|
| 49 |
+
dataset_version: str,
|
| 50 |
+
mutation_depth: int,
|
| 51 |
+
attack_types: list[str],
|
| 52 |
+
) -> str:
|
| 53 |
+
"""Generate SHA256 hash of configuration for reproducibility."""
|
| 54 |
+
config = {
|
| 55 |
+
"model_name": model_name,
|
| 56 |
+
"model_version": model_version,
|
| 57 |
+
"dataset_name": dataset_name,
|
| 58 |
+
"dataset_version": dataset_version,
|
| 59 |
+
"mutation_depth": mutation_depth,
|
| 60 |
+
"attack_types": sorted(attack_types),
|
| 61 |
+
}
|
| 62 |
+
config_str = json.dumps(config, sort_keys=True)
|
| 63 |
+
return hashlib.sha256(config_str.encode()).hexdigest()
|
| 64 |
+
|
| 65 |
+
async def submit_job(
|
| 66 |
+
self,
|
| 67 |
+
request: JobSubmissionRequest,
|
| 68 |
+
submitted_by: Optional[str] = None,
|
| 69 |
+
) -> EvaluationJob:
|
| 70 |
+
"""
|
| 71 |
+
Submit a new evaluation job.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
request: Job submission request
|
| 75 |
+
submitted_by: API key owner who submitted the job
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
Created evaluation job
|
| 79 |
+
"""
|
| 80 |
+
try:
|
| 81 |
+
# Generate config hash
|
| 82 |
+
config_hash = self._generate_config_hash(
|
| 83 |
+
model_name=request.model_name,
|
| 84 |
+
model_version=request.model_version,
|
| 85 |
+
dataset_name=request.dataset_name,
|
| 86 |
+
dataset_version=request.dataset_version,
|
| 87 |
+
mutation_depth=request.mutation_depth,
|
| 88 |
+
attack_types=request.attack_types,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Create job
|
| 92 |
+
job = EvaluationJob(
|
| 93 |
+
job_id=uuid.uuid4(),
|
| 94 |
+
job_type=request.job_type,
|
| 95 |
+
model_name=request.model_name,
|
| 96 |
+
model_version=request.model_version,
|
| 97 |
+
dataset_name=request.dataset_name,
|
| 98 |
+
dataset_version=request.dataset_version,
|
| 99 |
+
config_hash=config_hash,
|
| 100 |
+
priority=request.priority,
|
| 101 |
+
submitted_by=submitted_by,
|
| 102 |
+
status=JobStatus.PENDING,
|
| 103 |
+
progress=0.0,
|
| 104 |
+
total_samples=0, # Will be updated when job starts
|
| 105 |
+
completed_samples=0,
|
| 106 |
+
failed_samples=0,
|
| 107 |
+
checkpoint_interval=request.checkpoint_interval,
|
| 108 |
+
created_at=datetime.utcnow(),
|
| 109 |
+
metadata={
|
| 110 |
+
"mutation_depth": request.mutation_depth,
|
| 111 |
+
"attack_types": request.attack_types,
|
| 112 |
+
"max_concurrency": request.max_concurrency,
|
| 113 |
+
"sampling_config": request.sampling_config,
|
| 114 |
+
},
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Create job in database
|
| 118 |
+
await self._status_tracker.create_job(job)
|
| 119 |
+
|
| 120 |
+
# Update status to queued
|
| 121 |
+
await self._status_tracker.update_job_status(
|
| 122 |
+
job.job_id,
|
| 123 |
+
JobStatus.QUEUED,
|
| 124 |
+
)
|
| 125 |
+
job.status = JobStatus.QUEUED
|
| 126 |
+
job.queued_at = datetime.utcnow()
|
| 127 |
+
|
| 128 |
+
# Add to in-memory queue (for now, using simple list)
|
| 129 |
+
# In production, this would use Redis/RQ/Celery
|
| 130 |
+
_job_queue.append(job)
|
| 131 |
+
|
| 132 |
+
logger.info(
|
| 133 |
+
"Job submitted",
|
| 134 |
+
job_id=str(job.job_id),
|
| 135 |
+
job_type=job.job_type,
|
| 136 |
+
model=job.model_name,
|
| 137 |
+
dataset=job.dataset_name,
|
| 138 |
+
priority=job.priority,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return job
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
logger.error(
|
| 145 |
+
"Failed to submit job",
|
| 146 |
+
error=str(e),
|
| 147 |
+
)
|
| 148 |
+
raise
|
| 149 |
+
|
| 150 |
+
async def submit_benchmark_job(
|
| 151 |
+
self,
|
| 152 |
+
model_name: str,
|
| 153 |
+
model_version: str,
|
| 154 |
+
dataset_version: str,
|
| 155 |
+
submitted_by: Optional[str] = None,
|
| 156 |
+
priority: JobPriority = JobPriority.NORMAL,
|
| 157 |
+
mutation_depth: int = 2,
|
| 158 |
+
attack_types: Optional[list[str]] = None,
|
| 159 |
+
max_concurrency: int = 4,
|
| 160 |
+
checkpoint_interval: int = 10,
|
| 161 |
+
) -> EvaluationJob:
|
| 162 |
+
"""
|
| 163 |
+
Submit a benchmark evaluation job.
|
| 164 |
+
|
| 165 |
+
Convenience method for submitting standard benchmark jobs.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
model_name: Name of the model to evaluate
|
| 169 |
+
model_version: Model version
|
| 170 |
+
dataset_version: Dataset version to use
|
| 171 |
+
submitted_by: API key owner
|
| 172 |
+
priority: Job priority
|
| 173 |
+
mutation_depth: Mutation depth
|
| 174 |
+
attack_types: List of attack types
|
| 175 |
+
max_concurrency: Maximum concurrent samples
|
| 176 |
+
checkpoint_interval: Checkpoint interval
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Created evaluation job
|
| 180 |
+
"""
|
| 181 |
+
request = JobSubmissionRequest(
|
| 182 |
+
job_type=JobType.BENCHMARK,
|
| 183 |
+
model_name=model_name,
|
| 184 |
+
model_version=model_version,
|
| 185 |
+
dataset_name="default",
|
| 186 |
+
dataset_version=dataset_version,
|
| 187 |
+
priority=priority,
|
| 188 |
+
mutation_depth=mutation_depth,
|
| 189 |
+
attack_types=attack_types or ["jailbreak"],
|
| 190 |
+
max_concurrency=max_concurrency,
|
| 191 |
+
checkpoint_interval=checkpoint_interval,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
return await self.submit_job(request, submitted_by)
|
| 195 |
+
|
| 196 |
+
async def cancel_job(
|
| 197 |
+
self,
|
| 198 |
+
job_id: uuid.UUID,
|
| 199 |
+
) -> bool:
|
| 200 |
+
"""
|
| 201 |
+
Cancel a job.
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
job_id: The job ID to cancel
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
True if cancelled, False if not found or already completed
|
| 208 |
+
"""
|
| 209 |
+
try:
|
| 210 |
+
job = self._status_tracker.get_cached_job(job_id)
|
| 211 |
+
|
| 212 |
+
if job is None:
|
| 213 |
+
logger.warning(
|
| 214 |
+
"Job not found for cancellation",
|
| 215 |
+
job_id=str(job_id),
|
| 216 |
+
)
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
# Check if job can be cancelled
|
| 220 |
+
if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
|
| 221 |
+
logger.info(
|
| 222 |
+
"Job already in terminal state",
|
| 223 |
+
job_id=str(job_id),
|
| 224 |
+
status=job.status,
|
| 225 |
+
)
|
| 226 |
+
return False
|
| 227 |
+
|
| 228 |
+
# Update status
|
| 229 |
+
await self._status_tracker.update_job_status(
|
| 230 |
+
job_id,
|
| 231 |
+
JobStatus.CANCELLED,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
logger.info(
|
| 235 |
+
"Job cancelled",
|
| 236 |
+
job_id=str(job_id),
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
return True
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.error(
|
| 243 |
+
"Failed to cancel job",
|
| 244 |
+
job_id=str(job_id),
|
| 245 |
+
error=str(e),
|
| 246 |
+
)
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
def get_queue_size(self) -> int:
|
| 250 |
+
"""Get current queue size."""
|
| 251 |
+
return len(_job_queue)
|
| 252 |
+
|
| 253 |
+
def get_pending_jobs(self) -> list[EvaluationJob]:
|
| 254 |
+
"""Get all pending jobs in queue."""
|
| 255 |
+
return [job for job in _job_queue if job.status == JobStatus.QUEUED]
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# In-memory job queue (for demonstration)
|
| 259 |
+
# In production, this would be replaced with Redis/RQ/Celery
|
| 260 |
+
_job_queue: list[EvaluationJob] = []
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# Global instance
|
| 264 |
+
_producer: Optional[JobProducer] = None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def get_job_producer() -> JobProducer:
|
| 268 |
+
"""Get the global job producer instance."""
|
| 269 |
+
global _producer
|
| 270 |
+
if _producer is None:
|
| 271 |
+
_producer = JobProducer()
|
| 272 |
+
return _producer
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
__all__ = [
|
| 276 |
+
"JobProducer",
|
| 277 |
+
"get_job_producer",
|
| 278 |
+
]
|
backend/queue/scheduler.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GPU-Aware Job Scheduler with Least-Load Assignment and Tenant Fairness
|
| 3 |
+
|
| 4 |
+
Provides intelligent job scheduling with:
|
| 5 |
+
- GPU affinity management
|
| 6 |
+
- Least-load worker selection
|
| 7 |
+
- Tenant fairness (preventing starvation)
|
| 8 |
+
- Atomic job claiming
|
| 9 |
+
- Fault tolerance
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from sqlalchemy import select, update, func
|
| 17 |
+
|
| 18 |
+
from backend.db.models import Worker, EvaluationRun
|
| 19 |
+
from backend.db.session import get_db_context
|
| 20 |
+
from backend.logging.logger import get_logger
|
| 21 |
+
|
| 22 |
+
from .job_schema import (
|
| 23 |
+
GPURequirement,
|
| 24 |
+
JobStatus,
|
| 25 |
+
EvaluationJob,
|
| 26 |
+
)
|
| 27 |
+
from .worker_registry import get_worker_registry, DEFAULT_HEARTBEAT_TIMEOUT
|
| 28 |
+
from .worker_schema import WorkerStatus
|
| 29 |
+
|
| 30 |
+
logger = get_logger("queue.scheduler", component="queue")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class JobScheduler:
|
| 34 |
+
"""
|
| 35 |
+
GPU-aware job scheduler with least-load assignment and tenant fairness.
|
| 36 |
+
|
| 37 |
+
Responsibilities:
|
| 38 |
+
- GPU affinity management
|
| 39 |
+
- Least-load worker selection
|
| 40 |
+
- Tenant fairness (weighted scheduling)
|
| 41 |
+
- Atomic job claiming
|
| 42 |
+
- Job assignment with capacity checking
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
self._worker_registry = get_worker_registry()
|
| 47 |
+
|
| 48 |
+
async def get_tenant_active_job_count(self, tenant_id: uuid.UUID) -> int:
|
| 49 |
+
"""
|
| 50 |
+
Get the number of active jobs for a tenant.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
tenant_id: The tenant ID
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Number of active jobs (pending or running)
|
| 57 |
+
"""
|
| 58 |
+
try:
|
| 59 |
+
async with get_db_context() as session:
|
| 60 |
+
query = select(func.count(EvaluationRun.id)).where(
|
| 61 |
+
EvaluationRun.tenant_id == tenant_id,
|
| 62 |
+
EvaluationRun.status.in_(["pending", "running"])
|
| 63 |
+
)
|
| 64 |
+
result = await session.execute(query)
|
| 65 |
+
return result.scalar() or 0
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(
|
| 68 |
+
"Failed to get tenant active job count",
|
| 69 |
+
tenant_id=str(tenant_id),
|
| 70 |
+
error=str(e),
|
| 71 |
+
)
|
| 72 |
+
return 0
|
| 73 |
+
|
| 74 |
+
async def get_all_tenant_job_counts(self) -> Dict[uuid.UUID, int]:
|
| 75 |
+
"""
|
| 76 |
+
Get active job counts for all tenants.
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Dictionary mapping tenant_id to active job count
|
| 80 |
+
"""
|
| 81 |
+
try:
|
| 82 |
+
async with get_db_context() as session:
|
| 83 |
+
query = (
|
| 84 |
+
select(EvaluationRun.tenant_id, func.count(EvaluationRun.id))
|
| 85 |
+
.where(EvaluationRun.status.in_(["pending", "running"]))
|
| 86 |
+
.group_by(EvaluationRun.tenant_id)
|
| 87 |
+
)
|
| 88 |
+
result = await session.execute(query)
|
| 89 |
+
return {row[0]: row[1] for row in result.all()}
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(
|
| 92 |
+
"Failed to get all tenant job counts",
|
| 93 |
+
error=str(e),
|
| 94 |
+
)
|
| 95 |
+
return {}
|
| 96 |
+
|
| 97 |
+
def calculate_tenant_priority(self, tenant_id: uuid.UUID, tenant_job_counts: Dict[uuid.UUID, int]) -> float:
|
| 98 |
+
"""
|
| 99 |
+
Calculate priority for a tenant based on job count.
|
| 100 |
+
|
| 101 |
+
Priority = 1 / (active_jobs_per_tenant + 1)
|
| 102 |
+
|
| 103 |
+
This gives higher priority to tenants with fewer active jobs,
|
| 104 |
+
preventing starvation.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
tenant_id: The tenant ID
|
| 108 |
+
tenant_job_counts: Dictionary of tenant job counts
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Priority score (higher is better)
|
| 112 |
+
"""
|
| 113 |
+
job_count = tenant_job_counts.get(tenant_id, 0)
|
| 114 |
+
# Add 1 to avoid division by zero and give new tenants highest priority
|
| 115 |
+
return 1.0 / (job_count + 1)
|
| 116 |
+
|
| 117 |
+
async def get_pending_jobs_with_tenant_fairness(
|
| 118 |
+
self,
|
| 119 |
+
jobs: List[EvaluationJob],
|
| 120 |
+
) -> List[EvaluationJob]:
|
| 121 |
+
"""
|
| 122 |
+
Sort pending jobs by tenant fairness priority.
|
| 123 |
+
|
| 124 |
+
Jobs from tenants with fewer active jobs get higher priority.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
jobs: List of pending jobs
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Sorted list of jobs
|
| 131 |
+
"""
|
| 132 |
+
if not jobs:
|
| 133 |
+
return jobs
|
| 134 |
+
|
| 135 |
+
# Get active job counts for all tenants
|
| 136 |
+
tenant_job_counts = await self.get_all_tenant_job_counts()
|
| 137 |
+
|
| 138 |
+
# Sort by tenant priority (highest priority first)
|
| 139 |
+
def get_priority(job: EvaluationJob) -> float:
|
| 140 |
+
if hasattr(job, 'tenant_id') and job.tenant_id:
|
| 141 |
+
return self.calculate_tenant_priority(job.tenant_id, tenant_job_counts)
|
| 142 |
+
return 0.0 # Jobs without tenant get lowest priority
|
| 143 |
+
|
| 144 |
+
return sorted(jobs, key=get_priority, reverse=True)
|
| 145 |
+
|
| 146 |
+
async def assign_job_to_worker(
|
| 147 |
+
self,
|
| 148 |
+
job: EvaluationJob,
|
| 149 |
+
) -> Optional[str]:
|
| 150 |
+
"""
|
| 151 |
+
Assign a job to the best available worker using least-load strategy.
|
| 152 |
+
|
| 153 |
+
The algorithm:
|
| 154 |
+
1. Filter workers by GPU requirement
|
| 155 |
+
2. Filter workers by status (ACTIVE or DEGRADED)
|
| 156 |
+
3. Filter workers with capacity (active_jobs < max_concurrent_jobs)
|
| 157 |
+
4. Sort by load factor (active_jobs / max_concurrent_jobs)
|
| 158 |
+
5. Select the worker with lowest load factor
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
job: The job to assign
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Worker ID if assigned, None if no suitable worker found
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
# Determine GPU requirement from job
|
| 168 |
+
gpu_required = self._get_gpu_requirement(job)
|
| 169 |
+
|
| 170 |
+
# Get available workers
|
| 171 |
+
available_workers = await self._worker_registry.get_available_workers(
|
| 172 |
+
gpu_required=gpu_required
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if not available_workers:
|
| 176 |
+
logger.warning(
|
| 177 |
+
"No available workers for job",
|
| 178 |
+
job_id=str(job.job_id),
|
| 179 |
+
gpu_required=gpu_required,
|
| 180 |
+
)
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
# Select worker with least load
|
| 184 |
+
selected_worker = None
|
| 185 |
+
min_load = float('inf')
|
| 186 |
+
|
| 187 |
+
for worker in available_workers:
|
| 188 |
+
# Calculate load factor
|
| 189 |
+
if worker.max_concurrent_jobs > 0:
|
| 190 |
+
load_factor = worker.active_jobs / worker.max_concurrent_jobs
|
| 191 |
+
else:
|
| 192 |
+
load_factor = float('inf')
|
| 193 |
+
|
| 194 |
+
# Check GPU capacity if GPU required
|
| 195 |
+
if gpu_required > 0:
|
| 196 |
+
# Check if worker has enough free GPU memory
|
| 197 |
+
free_gpu_memory = worker.gpu_memory_total - worker.gpu_memory_used
|
| 198 |
+
if free_gpu_memory < 4000: # Require at least 4GB free per job
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
if load_factor < min_load:
|
| 202 |
+
min_load = load_factor
|
| 203 |
+
selected_worker = worker
|
| 204 |
+
|
| 205 |
+
if selected_worker is None:
|
| 206 |
+
logger.warning(
|
| 207 |
+
"No worker with sufficient capacity",
|
| 208 |
+
job_id=str(job.job_id),
|
| 209 |
+
)
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
# Atomically claim the job
|
| 213 |
+
worker_id = await self._claim_job_for_worker(
|
| 214 |
+
job_id=job.job_id,
|
| 215 |
+
worker_id=selected_worker.worker_id,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
if worker_id:
|
| 219 |
+
logger.info(
|
| 220 |
+
"Job assigned to worker",
|
| 221 |
+
job_id=str(job.job_id),
|
| 222 |
+
worker_id=worker_id,
|
| 223 |
+
load_factor=min_load,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
return worker_id
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(
|
| 230 |
+
"Failed to assign job to worker",
|
| 231 |
+
job_id=str(job.job_id),
|
| 232 |
+
error=str(e),
|
| 233 |
+
)
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
async def _claim_job_for_worker(
|
| 237 |
+
self,
|
| 238 |
+
job_id: uuid.UUID,
|
| 239 |
+
worker_id: str,
|
| 240 |
+
) -> Optional[str]:
|
| 241 |
+
"""
|
| 242 |
+
Atomically claim a job for a worker.
|
| 243 |
+
|
| 244 |
+
Uses atomic UPDATE to prevent duplicate job execution.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
job_id: Job ID
|
| 248 |
+
worker_id: Worker ID
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
Worker ID if claimed successfully, None if already claimed
|
| 252 |
+
"""
|
| 253 |
+
try:
|
| 254 |
+
from backend.queue.producer import _job_queue
|
| 255 |
+
|
| 256 |
+
# Find the job in the queue
|
| 257 |
+
job = None
|
| 258 |
+
for j in _job_queue:
|
| 259 |
+
if j.job_id == job_id:
|
| 260 |
+
job = j
|
| 261 |
+
break
|
| 262 |
+
|
| 263 |
+
if job is None:
|
| 264 |
+
logger.warning(
|
| 265 |
+
"Job not found in queue",
|
| 266 |
+
job_id=str(job_id),
|
| 267 |
+
)
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
# Check if job is still queued (not already claimed)
|
| 271 |
+
if job.status != JobStatus.QUEUED:
|
| 272 |
+
logger.warning(
|
| 273 |
+
"Job not in QUEUED status",
|
| 274 |
+
job_id=str(job_id),
|
| 275 |
+
status=job.status,
|
| 276 |
+
)
|
| 277 |
+
return None
|
| 278 |
+
|
| 279 |
+
# Atomically update job status and worker
|
| 280 |
+
job.status = JobStatus.RUNNING
|
| 281 |
+
job.worker_id = worker_id
|
| 282 |
+
job.started_at = datetime.utcnow()
|
| 283 |
+
|
| 284 |
+
# Update worker active jobs count
|
| 285 |
+
async with get_db_context() as session:
|
| 286 |
+
stmt = (
|
| 287 |
+
update(Worker)
|
| 288 |
+
.where(Worker.worker_id == worker_id)
|
| 289 |
+
.values(active_jobs=Worker.active_jobs + 1)
|
| 290 |
+
)
|
| 291 |
+
await session.execute(stmt)
|
| 292 |
+
await session.commit()
|
| 293 |
+
|
| 294 |
+
logger.debug(
|
| 295 |
+
"Job claimed atomically",
|
| 296 |
+
job_id=str(job_id),
|
| 297 |
+
worker_id=worker_id,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
return worker_id
|
| 301 |
+
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error(
|
| 304 |
+
"Failed to claim job",
|
| 305 |
+
job_id=str(job_id),
|
| 306 |
+
worker_id=worker_id,
|
| 307 |
+
error=str(e),
|
| 308 |
+
)
|
| 309 |
+
return None
|
| 310 |
+
|
| 311 |
+
def _get_gpu_requirement(self, job: EvaluationJob) -> int:
|
| 312 |
+
"""
|
| 313 |
+
Determine GPU requirement from job.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
job: The job
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Number of GPUs required (0 for CPU-only)
|
| 320 |
+
"""
|
| 321 |
+
# Check job metadata for GPU requirement
|
| 322 |
+
if job.metadata:
|
| 323 |
+
gpu_req = job.metadata.get("gpu_requirement")
|
| 324 |
+
if gpu_req is not None:
|
| 325 |
+
return int(gpu_req)
|
| 326 |
+
|
| 327 |
+
# Infer from job type
|
| 328 |
+
job_type = job.metadata.get("job_type")
|
| 329 |
+
if job_type == "benchmark":
|
| 330 |
+
return 1 # Benchmark jobs typically need GPU
|
| 331 |
+
elif job_type == "single_eval":
|
| 332 |
+
return 0 # Single eval can run on CPU
|
| 333 |
+
|
| 334 |
+
# Default to 1 GPU for benchmark jobs
|
| 335 |
+
if hasattr(job, 'job_type') and job.job_type == "benchmark":
|
| 336 |
+
return 1
|
| 337 |
+
|
| 338 |
+
return 0
|
| 339 |
+
|
| 340 |
+
async def release_job_from_worker(
|
| 341 |
+
self,
|
| 342 |
+
job_id: uuid.UUID,
|
| 343 |
+
worker_id: str,
|
| 344 |
+
) -> bool:
|
| 345 |
+
"""
|
| 346 |
+
Release a job from a worker (job completed or failed).
|
| 347 |
+
|
| 348 |
+
Args:
|
| 349 |
+
job_id: Job ID
|
| 350 |
+
worker_id: Worker ID
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
True if released successfully
|
| 354 |
+
"""
|
| 355 |
+
try:
|
| 356 |
+
# Update worker active jobs count
|
| 357 |
+
async with get_db_context() as session:
|
| 358 |
+
stmt = (
|
| 359 |
+
update(Worker)
|
| 360 |
+
.where(Worker.worker_id == worker_id)
|
| 361 |
+
.where(Worker.active_jobs > 0)
|
| 362 |
+
.values(active_jobs=Worker.active_jobs - 1)
|
| 363 |
+
)
|
| 364 |
+
await session.execute(stmt)
|
| 365 |
+
await session.commit()
|
| 366 |
+
|
| 367 |
+
logger.debug(
|
| 368 |
+
"Job released from worker",
|
| 369 |
+
job_id=str(job_id),
|
| 370 |
+
worker_id=worker_id,
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
return True
|
| 374 |
+
|
| 375 |
+
except Exception as e:
|
| 376 |
+
logger.error(
|
| 377 |
+
"Failed to release job from worker",
|
| 378 |
+
job_id=str(job_id),
|
| 379 |
+
worker_id=worker_id,
|
| 380 |
+
error=str(e),
|
| 381 |
+
)
|
| 382 |
+
return False
|
| 383 |
+
|
| 384 |
+
async def get_worker_for_job(
|
| 385 |
+
self,
|
| 386 |
+
gpu_required: int = 0,
|
| 387 |
+
) -> Optional[Worker]:
|
| 388 |
+
"""
|
| 389 |
+
Get the best worker for a job with given GPU requirements.
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
gpu_required: Number of GPUs required
|
| 393 |
+
|
| 394 |
+
Returns:
|
| 395 |
+
Best worker or None
|
| 396 |
+
"""
|
| 397 |
+
available_workers = await self._worker_registry.get_available_workers(
|
| 398 |
+
gpu_required=gpu_required
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
if not available_workers:
|
| 402 |
+
return None
|
| 403 |
+
|
| 404 |
+
return available_workers[0] # Already sorted by load factor
|
| 405 |
+
|
| 406 |
+
async def check_gpu_capacity(
|
| 407 |
+
self,
|
| 408 |
+
worker_id: str,
|
| 409 |
+
gpu_required: int,
|
| 410 |
+
) -> bool:
|
| 411 |
+
"""
|
| 412 |
+
Check if a worker has sufficient GPU capacity for a job.
|
| 413 |
+
|
| 414 |
+
Args:
|
| 415 |
+
worker_id: Worker ID
|
| 416 |
+
gpu_required: GPUs required
|
| 417 |
+
|
| 418 |
+
Returns:
|
| 419 |
+
True if worker has sufficient capacity
|
| 420 |
+
"""
|
| 421 |
+
try:
|
| 422 |
+
async with get_db_context() as session:
|
| 423 |
+
stmt = select(Worker).where(Worker.worker_id == worker_id)
|
| 424 |
+
result = await session.execute(stmt)
|
| 425 |
+
worker = result.scalar_one_or_none()
|
| 426 |
+
|
| 427 |
+
if worker is None:
|
| 428 |
+
return False
|
| 429 |
+
|
| 430 |
+
# Check GPU count
|
| 431 |
+
if worker.gpu_count < gpu_required:
|
| 432 |
+
return False
|
| 433 |
+
|
| 434 |
+
# Check GPU memory
|
| 435 |
+
free_memory = worker.gpu_memory_total - worker.gpu_memory_used
|
| 436 |
+
required_memory = gpu_required * 4000 # 4GB per GPU minimum
|
| 437 |
+
|
| 438 |
+
return free_memory >= required_memory
|
| 439 |
+
|
| 440 |
+
except Exception as e:
|
| 441 |
+
logger.error(
|
| 442 |
+
"Failed to check GPU capacity",
|
| 443 |
+
worker_id=worker_id,
|
| 444 |
+
error=str(e),
|
| 445 |
+
)
|
| 446 |
+
return False
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# Global instance
|
| 450 |
+
_scheduler: Optional[JobScheduler] = None
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def get_job_scheduler() -> JobScheduler:
|
| 454 |
+
"""Get the global job scheduler instance."""
|
| 455 |
+
global _scheduler
|
| 456 |
+
if _scheduler is None:
|
| 457 |
+
_scheduler = JobScheduler()
|
| 458 |
+
return _scheduler
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
__all__ = [
|
| 462 |
+
"JobScheduler",
|
| 463 |
+
"get_job_scheduler",
|
| 464 |
+
]
|
backend/queue/status_tracker.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Status Tracker for Evaluation Jobs
|
| 3 |
+
|
| 4 |
+
Provides job status tracking with database persistence.
|
| 5 |
+
Handles job state transitions and progress updates.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Dict, Optional
|
| 11 |
+
|
| 12 |
+
from sqlalchemy import select, update
|
| 13 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
+
|
| 15 |
+
from backend.db.models import EvaluationRun
|
| 16 |
+
from backend.db.session import get_db_context
|
| 17 |
+
from backend.logging.logger import get_logger
|
| 18 |
+
|
| 19 |
+
from .job_schema import (
|
| 20 |
+
JobProgressUpdate,
|
| 21 |
+
JobStatus,
|
| 22 |
+
JobType,
|
| 23 |
+
JobPriority,
|
| 24 |
+
EvaluationJob,
|
| 25 |
+
JobStatusResponse,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
logger = get_logger("queue.status_tracker", component="queue")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class JobStatusTracker:
|
| 33 |
+
"""
|
| 34 |
+
Tracks job status and manages state transitions.
|
| 35 |
+
|
| 36 |
+
Provides methods for:
|
| 37 |
+
- Creating new jobs
|
| 38 |
+
- Updating job progress
|
| 39 |
+
- Querying job status
|
| 40 |
+
- Managing job lifecycle
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self):
|
| 44 |
+
self._cache: Dict[str, EvaluationJob] = {}
|
| 45 |
+
|
| 46 |
+
async def create_job(
|
| 47 |
+
self,
|
| 48 |
+
job: EvaluationJob,
|
| 49 |
+
) -> EvaluationJob:
|
| 50 |
+
"""
|
| 51 |
+
Create a new job in the database.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
job: The job to create
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
The created job
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
async with get_db_context() as session:
|
| 61 |
+
# Create evaluation run record
|
| 62 |
+
run = EvaluationRun(
|
| 63 |
+
id=job.job_id,
|
| 64 |
+
model_name=job.model_name,
|
| 65 |
+
model_version=job.model_version,
|
| 66 |
+
dataset_version=job.dataset_version,
|
| 67 |
+
status=job.status.value,
|
| 68 |
+
config_hash=job.config_hash,
|
| 69 |
+
)
|
| 70 |
+
session.add(run)
|
| 71 |
+
await session.commit()
|
| 72 |
+
|
| 73 |
+
logger.info(
|
| 74 |
+
"Job created",
|
| 75 |
+
job_id=str(job.job_id),
|
| 76 |
+
job_type=job.job_type,
|
| 77 |
+
priority=job.priority,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Cache the job
|
| 81 |
+
self._cache[str(job.job_id)] = job
|
| 82 |
+
|
| 83 |
+
return job
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(
|
| 87 |
+
"Failed to create job",
|
| 88 |
+
job_id=str(job.job_id),
|
| 89 |
+
error=str(e),
|
| 90 |
+
)
|
| 91 |
+
raise
|
| 92 |
+
|
| 93 |
+
async def update_job_status(
|
| 94 |
+
self,
|
| 95 |
+
job_id: uuid.UUID,
|
| 96 |
+
status: JobStatus,
|
| 97 |
+
error: Optional[str] = None,
|
| 98 |
+
) -> None:
|
| 99 |
+
"""
|
| 100 |
+
Update job status in database.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
job_id: The job ID
|
| 104 |
+
status: New status
|
| 105 |
+
error: Optional error message
|
| 106 |
+
"""
|
| 107 |
+
try:
|
| 108 |
+
async with get_db_context() as session:
|
| 109 |
+
stmt = (
|
| 110 |
+
update(EvaluationRun)
|
| 111 |
+
.where(EvaluationRun.id == job_id)
|
| 112 |
+
.values(status=status.value)
|
| 113 |
+
)
|
| 114 |
+
await session.execute(stmt)
|
| 115 |
+
await session.commit()
|
| 116 |
+
|
| 117 |
+
# Update cache
|
| 118 |
+
job_id_str = str(job_id)
|
| 119 |
+
if job_id_str in self._cache:
|
| 120 |
+
self._cache[job_id_str].status = status
|
| 121 |
+
if error:
|
| 122 |
+
self._cache[job_id_str].error = error
|
| 123 |
+
|
| 124 |
+
logger.info(
|
| 125 |
+
"Job status updated",
|
| 126 |
+
job_id=job_id_str,
|
| 127 |
+
status=status.value,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(
|
| 132 |
+
"Failed to update job status",
|
| 133 |
+
job_id=str(job_id),
|
| 134 |
+
error=str(e),
|
| 135 |
+
)
|
| 136 |
+
raise
|
| 137 |
+
|
| 138 |
+
async def update_job_progress(
|
| 139 |
+
self,
|
| 140 |
+
progress_update: JobProgressUpdate,
|
| 141 |
+
) -> None:
|
| 142 |
+
"""
|
| 143 |
+
Update job progress in database.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
progress_update: The progress update
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
job_id_str = str(progress_update.job_id)
|
| 150 |
+
|
| 151 |
+
# Update cached job
|
| 152 |
+
if job_id_str in self._cache:
|
| 153 |
+
job = self._cache[job_id_str]
|
| 154 |
+
job.completed_samples = progress_update.completed_samples
|
| 155 |
+
job.failed_samples = progress_update.failed_samples
|
| 156 |
+
|
| 157 |
+
if progress_update.composite_score is not None:
|
| 158 |
+
job.composite_score = progress_update.composite_score
|
| 159 |
+
if progress_update.metrics is not None:
|
| 160 |
+
job.metrics = progress_update.metrics
|
| 161 |
+
|
| 162 |
+
# Calculate progress percentage
|
| 163 |
+
if job.total_samples > 0:
|
| 164 |
+
total_done = progress_update.completed_samples + progress_update.failed_samples
|
| 165 |
+
job.progress = (total_done / job.total_samples) * 100
|
| 166 |
+
|
| 167 |
+
job.last_checkpoint_at = progress_update.checkpoint_at
|
| 168 |
+
|
| 169 |
+
# Update database
|
| 170 |
+
async with get_db_context() as session:
|
| 171 |
+
# Update composite score if available
|
| 172 |
+
stmt = select(EvaluationRun).where(
|
| 173 |
+
EvaluationRun.id == progress_update.job_id
|
| 174 |
+
)
|
| 175 |
+
result = await session.execute(stmt)
|
| 176 |
+
run = result.scalar_one_or_none()
|
| 177 |
+
|
| 178 |
+
if run:
|
| 179 |
+
if progress_update.composite_score is not None:
|
| 180 |
+
run.composite_score = progress_update.composite_score
|
| 181 |
+
await session.commit()
|
| 182 |
+
|
| 183 |
+
logger.debug(
|
| 184 |
+
"Job progress updated",
|
| 185 |
+
job_id=job_id_str,
|
| 186 |
+
completed=progress_update.completed_samples,
|
| 187 |
+
failed=progress_update.failed_samples,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(
|
| 192 |
+
"Failed to update job progress",
|
| 193 |
+
job_id=str(progress_update.job_id),
|
| 194 |
+
error=str(e),
|
| 195 |
+
)
|
| 196 |
+
raise
|
| 197 |
+
|
| 198 |
+
async def get_job_status(
|
| 199 |
+
self,
|
| 200 |
+
job_id: uuid.UUID,
|
| 201 |
+
) -> Optional[JobStatusResponse]:
|
| 202 |
+
"""
|
| 203 |
+
Get job status from database.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
job_id: The job ID
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
Job status response or None if not found
|
| 210 |
+
"""
|
| 211 |
+
# Check cache first
|
| 212 |
+
job_id_str = str(job_id)
|
| 213 |
+
if job_id_str in self._cache:
|
| 214 |
+
job = self._cache[job_id_str]
|
| 215 |
+
return JobStatusResponse(
|
| 216 |
+
job_id=job.job_id,
|
| 217 |
+
job_type=JobType(job.job_type),
|
| 218 |
+
status=JobStatus(job.status),
|
| 219 |
+
progress=job.progress,
|
| 220 |
+
total_samples=job.total_samples,
|
| 221 |
+
completed_samples=job.completed_samples,
|
| 222 |
+
failed_samples=job.failed_samples,
|
| 223 |
+
composite_score=job.composite_score,
|
| 224 |
+
metrics=job.metrics,
|
| 225 |
+
error=job.error,
|
| 226 |
+
created_at=job.created_at,
|
| 227 |
+
started_at=job.started_at,
|
| 228 |
+
completed_at=job.completed_at,
|
| 229 |
+
worker_id=job.worker_id,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Fetch from database
|
| 233 |
+
try:
|
| 234 |
+
async with get_db_context() as session:
|
| 235 |
+
stmt = select(EvaluationRun).where(
|
| 236 |
+
EvaluationRun.id == job_id
|
| 237 |
+
)
|
| 238 |
+
result = await session.execute(stmt)
|
| 239 |
+
run = result.scalar_one_or_none()
|
| 240 |
+
|
| 241 |
+
if run is None:
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
# Build response from DB
|
| 245 |
+
return JobStatusResponse(
|
| 246 |
+
job_id=run.id,
|
| 247 |
+
job_type=JobType.BENCHMARK,
|
| 248 |
+
status=JobStatus(run.status),
|
| 249 |
+
progress=0.0,
|
| 250 |
+
total_samples=0,
|
| 251 |
+
completed_samples=0,
|
| 252 |
+
failed_samples=0,
|
| 253 |
+
composite_score=run.composite_score,
|
| 254 |
+
metrics=None,
|
| 255 |
+
error=None,
|
| 256 |
+
created_at=run.timestamp,
|
| 257 |
+
started_at=None,
|
| 258 |
+
completed_at=None,
|
| 259 |
+
worker_id=None,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(
|
| 264 |
+
"Failed to get job status",
|
| 265 |
+
job_id=str(job_id),
|
| 266 |
+
error=str(e),
|
| 267 |
+
)
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
async def complete_job(
|
| 271 |
+
self,
|
| 272 |
+
job_id: uuid.UUID,
|
| 273 |
+
composite_score: Optional[float] = None,
|
| 274 |
+
metrics: Optional[dict] = None,
|
| 275 |
+
) -> None:
|
| 276 |
+
"""
|
| 277 |
+
Mark job as completed.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
job_id: The job ID
|
| 281 |
+
composite_score: Final composite score
|
| 282 |
+
metrics: Final metrics
|
| 283 |
+
"""
|
| 284 |
+
try:
|
| 285 |
+
job_id_str = str(job_id)
|
| 286 |
+
|
| 287 |
+
async with get_db_context() as session:
|
| 288 |
+
stmt = (
|
| 289 |
+
update(EvaluationRun)
|
| 290 |
+
.where(EvaluationRun.id == job_id)
|
| 291 |
+
.values(
|
| 292 |
+
status=JobStatus.COMPLETED.value,
|
| 293 |
+
composite_score=composite_score,
|
| 294 |
+
)
|
| 295 |
+
)
|
| 296 |
+
await session.execute(stmt)
|
| 297 |
+
await session.commit()
|
| 298 |
+
|
| 299 |
+
# Update cache
|
| 300 |
+
if job_id_str in self._cache:
|
| 301 |
+
job = self._cache[job_id_str]
|
| 302 |
+
job.status = JobStatus.COMPLETED
|
| 303 |
+
job.composite_score = composite_score
|
| 304 |
+
job.metrics = metrics
|
| 305 |
+
job.completed_at = datetime.utcnow()
|
| 306 |
+
job.progress = 100.0
|
| 307 |
+
|
| 308 |
+
logger.info(
|
| 309 |
+
"Job completed",
|
| 310 |
+
job_id=job_id_str,
|
| 311 |
+
composite_score=composite_score,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
logger.error(
|
| 316 |
+
"Failed to complete job",
|
| 317 |
+
job_id=str(job_id),
|
| 318 |
+
error=str(e),
|
| 319 |
+
)
|
| 320 |
+
raise
|
| 321 |
+
|
| 322 |
+
async def fail_job(
|
| 323 |
+
self,
|
| 324 |
+
job_id: uuid.UUID,
|
| 325 |
+
error: str,
|
| 326 |
+
error_details: Optional[dict] = None,
|
| 327 |
+
) -> None:
|
| 328 |
+
"""
|
| 329 |
+
Mark job as failed.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
job_id: The job ID
|
| 333 |
+
error: Error message
|
| 334 |
+
error_details: Optional error details
|
| 335 |
+
"""
|
| 336 |
+
try:
|
| 337 |
+
job_id_str = str(job_id)
|
| 338 |
+
|
| 339 |
+
async with get_db_context() as session:
|
| 340 |
+
stmt = (
|
| 341 |
+
update(EvaluationRun)
|
| 342 |
+
.where(EvaluationRun.id == job_id)
|
| 343 |
+
.values(
|
| 344 |
+
status=JobStatus.FAILED.value,
|
| 345 |
+
)
|
| 346 |
+
)
|
| 347 |
+
await session.execute(stmt)
|
| 348 |
+
await session.commit()
|
| 349 |
+
|
| 350 |
+
# Update cache
|
| 351 |
+
if job_id_str in self._cache:
|
| 352 |
+
job = self._cache[job_id_str]
|
| 353 |
+
job.status = JobStatus.FAILED
|
| 354 |
+
job.error = error
|
| 355 |
+
job.error_details = error_details
|
| 356 |
+
job.completed_at = datetime.utcnow()
|
| 357 |
+
|
| 358 |
+
logger.error(
|
| 359 |
+
"Job failed",
|
| 360 |
+
job_id=job_id_str,
|
| 361 |
+
error=error,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
except Exception as e:
|
| 365 |
+
logger.error(
|
| 366 |
+
"Failed to mark job as failed",
|
| 367 |
+
job_id=str(job_id),
|
| 368 |
+
error=str(e),
|
| 369 |
+
)
|
| 370 |
+
raise
|
| 371 |
+
|
| 372 |
+
async def start_job(
|
| 373 |
+
self,
|
| 374 |
+
job_id: uuid.UUID,
|
| 375 |
+
worker_id: str,
|
| 376 |
+
) -> None:
|
| 377 |
+
"""
|
| 378 |
+
Mark job as started.
|
| 379 |
+
|
| 380 |
+
Args:
|
| 381 |
+
job_id: The job ID
|
| 382 |
+
worker_id: ID of the worker starting the job
|
| 383 |
+
"""
|
| 384 |
+
try:
|
| 385 |
+
job_id_str = str(job_id)
|
| 386 |
+
now = datetime.utcnow()
|
| 387 |
+
|
| 388 |
+
async with get_db_context() as session:
|
| 389 |
+
stmt = (
|
| 390 |
+
update(EvaluationRun)
|
| 391 |
+
.where(EvaluationRun.id == job_id)
|
| 392 |
+
.values(
|
| 393 |
+
status=JobStatus.RUNNING.value,
|
| 394 |
+
)
|
| 395 |
+
)
|
| 396 |
+
await session.execute(stmt)
|
| 397 |
+
await session.commit()
|
| 398 |
+
|
| 399 |
+
# Update cache
|
| 400 |
+
if job_id_str in self._cache:
|
| 401 |
+
job = self._cache[job_id_str]
|
| 402 |
+
job.status = JobStatus.RUNNING
|
| 403 |
+
job.worker_id = worker_id
|
| 404 |
+
job.started_at = now
|
| 405 |
+
|
| 406 |
+
logger.info(
|
| 407 |
+
"Job started",
|
| 408 |
+
job_id=job_id_str,
|
| 409 |
+
worker_id=worker_id,
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
except Exception as e:
|
| 413 |
+
logger.error(
|
| 414 |
+
"Failed to start job",
|
| 415 |
+
job_id=str(job_id),
|
| 416 |
+
error=str(e),
|
| 417 |
+
)
|
| 418 |
+
raise
|
| 419 |
+
|
| 420 |
+
def get_cached_job(self, job_id: uuid.UUID) -> Optional[EvaluationJob]:
|
| 421 |
+
"""Get job from cache."""
|
| 422 |
+
return self._cache.get(str(job_id))
|
| 423 |
+
|
| 424 |
+
def set_cached_job(self, job: EvaluationJob) -> None:
|
| 425 |
+
"""Set job in cache."""
|
| 426 |
+
self._cache[str(job.job_id)] = job
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# Global instance
|
| 430 |
+
status_tracker = JobStatusTracker()
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def get_status_tracker() -> JobStatusTracker:
|
| 434 |
+
"""Get the global status tracker instance."""
|
| 435 |
+
return status_tracker
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
__all__ = [
|
| 439 |
+
"JobStatusTracker",
|
| 440 |
+
"status_tracker",
|
| 441 |
+
"get_status_tracker",
|
| 442 |
+
"JobProgressUpdate",
|
| 443 |
+
]
|
backend/queue/worker_registry.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Worker Registry for Distributed Worker Coordination
|
| 3 |
+
|
| 4 |
+
Provides worker registration, heartbeat management, and worker health tracking.
|
| 5 |
+
Handles worker lifecycle: REGISTERED -> ACTIVE -> DEGRADED -> OFFLINE
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import socket
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
from sqlalchemy import select, update
|
| 14 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
+
|
| 16 |
+
from backend.db.models import Worker
|
| 17 |
+
from backend.db.session import get_db_context
|
| 18 |
+
from backend.logging.logger import get_logger
|
| 19 |
+
|
| 20 |
+
from .job_schema import JobStatus
|
| 21 |
+
from .worker_schema import (
|
| 22 |
+
GPUInfo,
|
| 23 |
+
HeartbeatRequest,
|
| 24 |
+
HeartbeatResponse,
|
| 25 |
+
WorkerRegistrationRequest,
|
| 26 |
+
WorkerRegistrationResponse,
|
| 27 |
+
WorkerStatus,
|
| 28 |
+
WorkerStatusResponse,
|
| 29 |
+
WorkerListResponse,
|
| 30 |
+
WorkerMetricsResponse,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
logger = get_logger("queue.worker_registry", component="queue")
|
| 34 |
+
|
| 35 |
+
# Configuration
|
| 36 |
+
DEFAULT_HEARTBEAT_INTERVAL = 30 # seconds
|
| 37 |
+
DEFAULT_HEARTBEAT_TIMEOUT = 120 # seconds (worker marked offline after this)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class WorkerRegistry:
|
| 41 |
+
"""
|
| 42 |
+
Manages worker registration, heartbeat, and health monitoring.
|
| 43 |
+
|
| 44 |
+
Responsibilities:
|
| 45 |
+
- Worker registration and deregistration
|
| 46 |
+
- Heartbeat processing
|
| 47 |
+
- Worker health tracking
|
| 48 |
+
- Offline worker detection
|
| 49 |
+
- Load factor calculation
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
self._cache: Dict[str, Worker] = {}
|
| 54 |
+
self._last_cleanup = datetime.utcnow()
|
| 55 |
+
self._cleanup_interval = 60 # Run cleanup every 60 seconds
|
| 56 |
+
|
| 57 |
+
def _generate_worker_id(self, hostname: str) -> str:
|
| 58 |
+
"""Generate a unique worker ID based on hostname and UUID."""
|
| 59 |
+
return f"{hostname}-{uuid.uuid4().hex[:8]}"
|
| 60 |
+
|
| 61 |
+
async def register_worker(
|
| 62 |
+
self,
|
| 63 |
+
request: WorkerRegistrationRequest,
|
| 64 |
+
) -> WorkerRegistrationResponse:
|
| 65 |
+
"""
|
| 66 |
+
Register a new worker in the cluster.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
request: Worker registration request
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Worker registration response with assigned worker_id
|
| 73 |
+
"""
|
| 74 |
+
try:
|
| 75 |
+
worker_id = self._generate_worker_id(request.hostname)
|
| 76 |
+
|
| 77 |
+
# Calculate total GPU memory if GPU info provided
|
| 78 |
+
gpu_memory_total = request.gpu_memory_total
|
| 79 |
+
if request.gpu_info:
|
| 80 |
+
gpu_memory_total = sum(gpu.memory_total_mb for gpu in request.gpu_info)
|
| 81 |
+
|
| 82 |
+
gpu_count = request.gpu_count
|
| 83 |
+
if request.gpu_info:
|
| 84 |
+
gpu_count = len(request.gpu_info)
|
| 85 |
+
|
| 86 |
+
async with get_db_context() as session:
|
| 87 |
+
# Create worker record
|
| 88 |
+
worker = Worker(
|
| 89 |
+
worker_id=worker_id,
|
| 90 |
+
hostname=request.hostname,
|
| 91 |
+
gpu_count=gpu_count,
|
| 92 |
+
gpu_memory_total=gpu_memory_total,
|
| 93 |
+
gpu_memory_used=0,
|
| 94 |
+
status=WorkerStatus.ACTIVE.value,
|
| 95 |
+
last_heartbeat=datetime.utcnow(),
|
| 96 |
+
active_jobs=0,
|
| 97 |
+
max_concurrent_jobs=request.max_concurrent_jobs,
|
| 98 |
+
capabilities=request.capabilities,
|
| 99 |
+
worker_metadata=request.worker_metadata,
|
| 100 |
+
)
|
| 101 |
+
session.add(worker)
|
| 102 |
+
await session.commit()
|
| 103 |
+
|
| 104 |
+
logger.info(
|
| 105 |
+
"Worker registered",
|
| 106 |
+
worker_id=worker_id,
|
| 107 |
+
hostname=request.hostname,
|
| 108 |
+
gpu_count=gpu_count,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Cache the worker
|
| 112 |
+
self._cache[worker_id] = worker
|
| 113 |
+
|
| 114 |
+
return WorkerRegistrationResponse(
|
| 115 |
+
worker_id=worker_id,
|
| 116 |
+
status=WorkerStatus.ACTIVE,
|
| 117 |
+
registered_at=worker.registered_at,
|
| 118 |
+
heartbeat_interval=DEFAULT_HEARTBEAT_INTERVAL,
|
| 119 |
+
heartbeat_timeout=DEFAULT_HEARTBEAT_TIMEOUT,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(
|
| 124 |
+
"Failed to register worker",
|
| 125 |
+
error=str(e),
|
| 126 |
+
hostname=request.hostname,
|
| 127 |
+
)
|
| 128 |
+
raise
|
| 129 |
+
|
| 130 |
+
async def heartbeat(
|
| 131 |
+
self,
|
| 132 |
+
request: HeartbeatRequest,
|
| 133 |
+
) -> HeartbeatResponse:
|
| 134 |
+
"""
|
| 135 |
+
Process worker heartbeat and update worker status.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
request: Heartbeat request with worker status
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
Heartbeat response
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
worker_id = request.worker_id
|
| 145 |
+
|
| 146 |
+
async with get_db_context() as session:
|
| 147 |
+
# Get worker from database
|
| 148 |
+
stmt = select(Worker).where(Worker.worker_id == worker_id)
|
| 149 |
+
result = await session.execute(stmt)
|
| 150 |
+
worker = result.scalar_one_or_none()
|
| 151 |
+
|
| 152 |
+
if worker is None:
|
| 153 |
+
logger.warning(
|
| 154 |
+
"Heartbeat from unknown worker",
|
| 155 |
+
worker_id=worker_id,
|
| 156 |
+
)
|
| 157 |
+
raise ValueError(f"Worker {worker_id} not found")
|
| 158 |
+
|
| 159 |
+
# Update worker status based on heartbeat
|
| 160 |
+
now = datetime.utcnow()
|
| 161 |
+
worker.last_heartbeat = now
|
| 162 |
+
|
| 163 |
+
# Update optional fields if provided
|
| 164 |
+
if request.gpu_usage is not None:
|
| 165 |
+
worker.gpu_usage_percent = request.gpu_usage
|
| 166 |
+
|
| 167 |
+
if request.gpu_memory_used is not None:
|
| 168 |
+
worker.gpu_memory_used = request.gpu_memory_used
|
| 169 |
+
|
| 170 |
+
if request.active_jobs is not None:
|
| 171 |
+
worker.active_jobs = request.active_jobs
|
| 172 |
+
|
| 173 |
+
# Update GPU info if provided
|
| 174 |
+
if request.gpu_info:
|
| 175 |
+
total_used = sum(gpu.memory_used_mb for gpu in request.gpu_info)
|
| 176 |
+
worker.gpu_memory_used = total_used
|
| 177 |
+
avg_util = sum(gpu.utilization_percent for gpu in request.gpu_info) / len(request.gpu_info)
|
| 178 |
+
worker.gpu_usage_percent = avg_util
|
| 179 |
+
|
| 180 |
+
# Update status if provided
|
| 181 |
+
if request.status is not None:
|
| 182 |
+
worker.status = request.status.value
|
| 183 |
+
|
| 184 |
+
# Determine if worker should be degraded
|
| 185 |
+
if worker.gpu_usage_percent > 90:
|
| 186 |
+
worker.status = WorkerStatus.DEGRADED.value
|
| 187 |
+
elif worker.active_jobs >= worker.max_concurrent_jobs:
|
| 188 |
+
worker.status = WorkerStatus.DEGRADED.value
|
| 189 |
+
elif worker.status == WorkerStatus.OFFLINE.value:
|
| 190 |
+
# Reactivate if was offline
|
| 191 |
+
worker.status = WorkerStatus.ACTIVE.value
|
| 192 |
+
|
| 193 |
+
await session.commit()
|
| 194 |
+
|
| 195 |
+
# Update cache
|
| 196 |
+
self._cache[worker_id] = worker
|
| 197 |
+
|
| 198 |
+
logger.debug(
|
| 199 |
+
"Heartbeat processed",
|
| 200 |
+
worker_id=worker_id,
|
| 201 |
+
active_jobs=worker.active_jobs,
|
| 202 |
+
gpu_usage=worker.gpu_usage_percent,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
return HeartbeatResponse(
|
| 206 |
+
worker_id=worker_id,
|
| 207 |
+
status=WorkerStatus(worker.status),
|
| 208 |
+
timestamp=now,
|
| 209 |
+
assigned_jobs=worker.active_jobs,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
except ValueError:
|
| 213 |
+
raise
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logger.error(
|
| 216 |
+
"Failed to process heartbeat",
|
| 217 |
+
worker_id=request.worker_id,
|
| 218 |
+
error=str(e),
|
| 219 |
+
)
|
| 220 |
+
raise
|
| 221 |
+
|
| 222 |
+
async def get_worker_status(
|
| 223 |
+
self,
|
| 224 |
+
worker_id: str,
|
| 225 |
+
) -> Optional[WorkerStatusResponse]:
|
| 226 |
+
"""
|
| 227 |
+
Get worker status by ID.
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
worker_id: Worker ID
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Worker status response or None if not found
|
| 234 |
+
"""
|
| 235 |
+
# Check cache first
|
| 236 |
+
if worker_id in self._cache:
|
| 237 |
+
worker = self._cache[worker_id]
|
| 238 |
+
return self._build_worker_status_response(worker)
|
| 239 |
+
|
| 240 |
+
# Fetch from database
|
| 241 |
+
try:
|
| 242 |
+
async with get_db_context() as session:
|
| 243 |
+
stmt = select(Worker).where(Worker.worker_id == worker_id)
|
| 244 |
+
result = await session.execute(stmt)
|
| 245 |
+
worker = result.scalar_one_or_none()
|
| 246 |
+
|
| 247 |
+
if worker is None:
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
return self._build_worker_status_response(worker)
|
| 251 |
+
|
| 252 |
+
except Exception as e:
|
| 253 |
+
logger.error(
|
| 254 |
+
"Failed to get worker status",
|
| 255 |
+
worker_id=worker_id,
|
| 256 |
+
error=str(e),
|
| 257 |
+
)
|
| 258 |
+
return None
|
| 259 |
+
|
| 260 |
+
async def list_workers(
|
| 261 |
+
self,
|
| 262 |
+
status_filter: Optional[WorkerStatus] = None,
|
| 263 |
+
) -> WorkerListResponse:
|
| 264 |
+
"""
|
| 265 |
+
List all workers in the cluster.
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
status_filter: Optional status filter
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
Worker list response
|
| 272 |
+
"""
|
| 273 |
+
try:
|
| 274 |
+
async with get_db_context() as session:
|
| 275 |
+
stmt = select(Worker).order_by(Worker.registered_at.desc())
|
| 276 |
+
if status_filter:
|
| 277 |
+
stmt = stmt.where(Worker.status == status_filter.value)
|
| 278 |
+
|
| 279 |
+
result = await session.execute(stmt)
|
| 280 |
+
workers = result.scalars().all()
|
| 281 |
+
|
| 282 |
+
# Update cache
|
| 283 |
+
for worker in workers:
|
| 284 |
+
self._cache[worker.worker_id] = worker
|
| 285 |
+
|
| 286 |
+
# Build responses
|
| 287 |
+
worker_responses = [
|
| 288 |
+
self._build_worker_status_response(w) for w in workers
|
| 289 |
+
]
|
| 290 |
+
|
| 291 |
+
active_count = sum(
|
| 292 |
+
1 for w in workers if w.status == WorkerStatus.ACTIVE.value
|
| 293 |
+
)
|
| 294 |
+
offline_count = sum(
|
| 295 |
+
1 for w in workers if w.status == WorkerStatus.OFFLINE.value
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return WorkerListResponse(
|
| 299 |
+
workers=worker_responses,
|
| 300 |
+
total=len(workers),
|
| 301 |
+
active=active_count,
|
| 302 |
+
offline=offline_count,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
except Exception as e:
|
| 306 |
+
logger.error(
|
| 307 |
+
"Failed to list workers",
|
| 308 |
+
error=str(e),
|
| 309 |
+
)
|
| 310 |
+
return WorkerListResponse(workers=[], total=0, active=0, offline=0)
|
| 311 |
+
|
| 312 |
+
async def get_worker_metrics(self) -> WorkerMetricsResponse:
|
| 313 |
+
"""
|
| 314 |
+
Get cluster-wide worker metrics.
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
Worker metrics response
|
| 318 |
+
"""
|
| 319 |
+
try:
|
| 320 |
+
async with get_db_context() as session:
|
| 321 |
+
stmt = select(Worker)
|
| 322 |
+
result = await session.execute(stmt)
|
| 323 |
+
workers = result.scalars().all()
|
| 324 |
+
|
| 325 |
+
total_workers = len(workers)
|
| 326 |
+
active_workers = sum(
|
| 327 |
+
1 for w in workers if w.status == WorkerStatus.ACTIVE.value
|
| 328 |
+
)
|
| 329 |
+
offline_workers = sum(
|
| 330 |
+
1 for w in workers if w.status == WorkerStatus.OFFLINE.value
|
| 331 |
+
)
|
| 332 |
+
degraded_workers = sum(
|
| 333 |
+
1 for w in workers if w.status == WorkerStatus.DEGRADED.value
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
total_gpus = sum(w.gpu_count for w in workers)
|
| 337 |
+
total_gpu_memory = sum(w.gpu_memory_total for w in workers)
|
| 338 |
+
used_gpu_memory = sum(w.gpu_memory_used for w in workers)
|
| 339 |
+
total_active_jobs = sum(w.active_jobs for w in workers)
|
| 340 |
+
|
| 341 |
+
# Calculate average load factor
|
| 342 |
+
if workers:
|
| 343 |
+
load_factors = [
|
| 344 |
+
w.active_jobs / w.max_concurrent_jobs
|
| 345 |
+
for w in workers
|
| 346 |
+
if w.max_concurrent_jobs > 0
|
| 347 |
+
]
|
| 348 |
+
avg_load = sum(load_factors) / len(load_factors) if load_factors else 0.0
|
| 349 |
+
else:
|
| 350 |
+
avg_load = 0.0
|
| 351 |
+
|
| 352 |
+
# Get queue length (pending jobs)
|
| 353 |
+
from backend.queue.producer import _job_queue
|
| 354 |
+
queue_length = sum(
|
| 355 |
+
1 for j in _job_queue
|
| 356 |
+
if j.status == JobStatus.QUEUED
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
return WorkerMetricsResponse(
|
| 360 |
+
total_workers=total_workers,
|
| 361 |
+
active_workers=active_workers,
|
| 362 |
+
offline_workers=offline_workers,
|
| 363 |
+
degraded_workers=degraded_workers,
|
| 364 |
+
total_gpus=total_gpus,
|
| 365 |
+
total_gpu_memory_mb=total_gpu_memory,
|
| 366 |
+
used_gpu_memory_mb=used_gpu_memory,
|
| 367 |
+
total_active_jobs=total_active_jobs,
|
| 368 |
+
average_load_factor=avg_load,
|
| 369 |
+
queue_length=queue_length,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
except Exception as e:
|
| 373 |
+
logger.error(
|
| 374 |
+
"Failed to get worker metrics",
|
| 375 |
+
error=str(e),
|
| 376 |
+
)
|
| 377 |
+
return WorkerMetricsResponse(
|
| 378 |
+
total_workers=0,
|
| 379 |
+
active_workers=0,
|
| 380 |
+
offline_workers=0,
|
| 381 |
+
degraded_workers=0,
|
| 382 |
+
total_gpus=0,
|
| 383 |
+
total_gpu_memory_mb=0,
|
| 384 |
+
used_gpu_memory_mb=0,
|
| 385 |
+
total_active_jobs=0,
|
| 386 |
+
average_load_factor=0.0,
|
| 387 |
+
queue_length=0,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
async def cleanup_offline_workers(self) -> int:
|
| 391 |
+
"""
|
| 392 |
+
Mark workers as offline if they haven't sent heartbeat within timeout.
|
| 393 |
+
|
| 394 |
+
Returns:
|
| 395 |
+
Number of workers marked offline
|
| 396 |
+
"""
|
| 397 |
+
try:
|
| 398 |
+
now = datetime.utcnow()
|
| 399 |
+
timeout_threshold = now - timedelta(seconds=DEFAULT_HEARTBEAT_TIMEOUT)
|
| 400 |
+
|
| 401 |
+
async with get_db_context() as session:
|
| 402 |
+
# Find workers that haven't sent heartbeat
|
| 403 |
+
stmt = (
|
| 404 |
+
update(Worker)
|
| 405 |
+
.where(Worker.last_heartbeat < timeout_threshold)
|
| 406 |
+
.where(Worker.status != WorkerStatus.OFFLINE.value)
|
| 407 |
+
.values(status=WorkerStatus.OFFLINE.value)
|
| 408 |
+
)
|
| 409 |
+
result = await session.execute(stmt)
|
| 410 |
+
await session.commit()
|
| 411 |
+
|
| 412 |
+
count = result.rowcount
|
| 413 |
+
|
| 414 |
+
if count > 0:
|
| 415 |
+
logger.info(
|
| 416 |
+
"Marked workers as offline",
|
| 417 |
+
count=count,
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
return count
|
| 421 |
+
|
| 422 |
+
except Exception as e:
|
| 423 |
+
logger.error(
|
| 424 |
+
"Failed to cleanup offline workers",
|
| 425 |
+
error=str(e),
|
| 426 |
+
)
|
| 427 |
+
return 0
|
| 428 |
+
|
| 429 |
+
async def requeue_worker_jobs(self, worker_id: str) -> int:
|
| 430 |
+
"""
|
| 431 |
+
Requeue jobs from an offline worker.
|
| 432 |
+
|
| 433 |
+
Args:
|
| 434 |
+
worker_id: Worker ID
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
Number of jobs requeued
|
| 438 |
+
"""
|
| 439 |
+
try:
|
| 440 |
+
from backend.queue.producer import _job_queue
|
| 441 |
+
|
| 442 |
+
requeued_count = 0
|
| 443 |
+
|
| 444 |
+
for job in _job_queue:
|
| 445 |
+
if job.worker_id == worker_id and job.status == JobStatus.RUNNING:
|
| 446 |
+
job.status = JobStatus.QUEUED
|
| 447 |
+
job.worker_id = None
|
| 448 |
+
job.started_at = None
|
| 449 |
+
requeued_count += 1
|
| 450 |
+
|
| 451 |
+
logger.info(
|
| 452 |
+
"Requeued job from offline worker",
|
| 453 |
+
job_id=str(job.job_id),
|
| 454 |
+
worker_id=worker_id,
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
return requeued_count
|
| 458 |
+
|
| 459 |
+
except Exception as e:
|
| 460 |
+
logger.error(
|
| 461 |
+
"Failed to requeue worker jobs",
|
| 462 |
+
worker_id=worker_id,
|
| 463 |
+
error=str(e),
|
| 464 |
+
)
|
| 465 |
+
return 0
|
| 466 |
+
|
| 467 |
+
def _build_worker_status_response(self, worker: Worker) -> WorkerStatusResponse:
|
| 468 |
+
"""Build worker status response from worker model."""
|
| 469 |
+
# Calculate load factor
|
| 470 |
+
load_factor = (
|
| 471 |
+
worker.active_jobs / worker.max_concurrent_jobs
|
| 472 |
+
if worker.max_concurrent_jobs > 0
|
| 473 |
+
else 0.0
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# Calculate uptime
|
| 477 |
+
uptime_seconds = int(
|
| 478 |
+
(datetime.utcnow() - worker.registered_at).total_seconds()
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
return WorkerStatusResponse(
|
| 482 |
+
worker_id=worker.worker_id,
|
| 483 |
+
hostname=worker.hostname,
|
| 484 |
+
status=WorkerStatus(worker.status),
|
| 485 |
+
gpu_count=worker.gpu_count,
|
| 486 |
+
gpu_memory_total=worker.gpu_memory_total,
|
| 487 |
+
gpu_memory_used=worker.gpu_memory_used,
|
| 488 |
+
gpu_usage_percent=worker.gpu_usage_percent,
|
| 489 |
+
active_jobs=worker.active_jobs,
|
| 490 |
+
max_concurrent_jobs=worker.max_concurrent_jobs,
|
| 491 |
+
load_factor=load_factor,
|
| 492 |
+
last_heartbeat=worker.last_heartbeat,
|
| 493 |
+
registered_at=worker.registered_at,
|
| 494 |
+
capabilities=worker.capabilities,
|
| 495 |
+
uptime_seconds=uptime_seconds,
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
async def get_available_workers(
|
| 499 |
+
self,
|
| 500 |
+
gpu_required: int = 0,
|
| 501 |
+
) -> List[Worker]:
|
| 502 |
+
"""
|
| 503 |
+
Get available workers that can accept new jobs.
|
| 504 |
+
|
| 505 |
+
Args:
|
| 506 |
+
gpu_required: Number of GPUs required (0 for CPU-only)
|
| 507 |
+
|
| 508 |
+
Returns:
|
| 509 |
+
List of available workers sorted by load factor
|
| 510 |
+
"""
|
| 511 |
+
try:
|
| 512 |
+
async with get_db_context() as session:
|
| 513 |
+
# Get active workers with capacity
|
| 514 |
+
stmt = (
|
| 515 |
+
select(Worker)
|
| 516 |
+
.where(Worker.status.in_([
|
| 517 |
+
WorkerStatus.ACTIVE.value,
|
| 518 |
+
WorkerStatus.DEGRADED.value,
|
| 519 |
+
]))
|
| 520 |
+
.where(Worker.active_jobs < Worker.max_concurrent_jobs)
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
if gpu_required > 0:
|
| 524 |
+
stmt = stmt.where(Worker.gpu_count >= gpu_required)
|
| 525 |
+
|
| 526 |
+
result = await session.execute(stmt)
|
| 527 |
+
workers = result.scalars().all()
|
| 528 |
+
|
| 529 |
+
# Sort by load factor (least loaded first)
|
| 530 |
+
sorted_workers = sorted(
|
| 531 |
+
workers,
|
| 532 |
+
key=lambda w: w.active_jobs / w.max_concurrent_jobs
|
| 533 |
+
if w.max_concurrent_jobs > 0
|
| 534 |
+
else 0.0
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
return list(sorted_workers)
|
| 538 |
+
|
| 539 |
+
except Exception as e:
|
| 540 |
+
logger.error(
|
| 541 |
+
"Failed to get available workers",
|
| 542 |
+
error=str(e),
|
| 543 |
+
)
|
| 544 |
+
return []
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
# Global instance
|
| 548 |
+
_worker_registry: Optional[WorkerRegistry] = None
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def get_worker_registry() -> WorkerRegistry:
|
| 552 |
+
"""Get the global worker registry instance."""
|
| 553 |
+
global _worker_registry
|
| 554 |
+
if _worker_registry is None:
|
| 555 |
+
_worker_registry = WorkerRegistry()
|
| 556 |
+
return _worker_registry
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
__all__ = [
|
| 560 |
+
"WorkerRegistry",
|
| 561 |
+
"get_worker_registry",
|
| 562 |
+
"DEFAULT_HEARTBEAT_INTERVAL",
|
| 563 |
+
"DEFAULT_HEARTBEAT_TIMEOUT",
|
| 564 |
+
]
|
backend/queue/worker_schema.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Worker Schemas for Distributed Worker Coordination
|
| 3 |
+
|
| 4 |
+
Defines Pydantic schemas for worker registration, heartbeat, and status management.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class WorkerStatus(str, Enum):
|
| 16 |
+
"""Worker status enumeration."""
|
| 17 |
+
REGISTERED = "registered"
|
| 18 |
+
ACTIVE = "active"
|
| 19 |
+
DEGRADED = "degraded"
|
| 20 |
+
OFFLINE = "offline"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class GPUInfo(BaseModel):
|
| 24 |
+
"""GPU information for a worker."""
|
| 25 |
+
gpu_index: int = Field(description="GPU index")
|
| 26 |
+
gpu_name: str = Field(description="GPU name")
|
| 27 |
+
memory_total_mb: int = Field(description="Total memory in MB")
|
| 28 |
+
memory_used_mb: int = Field(description="Used memory in MB")
|
| 29 |
+
utilization_percent: float = Field(default=0.0, description="GPU utilization %")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class WorkerRegistrationRequest(BaseModel):
|
| 33 |
+
"""Request model for worker registration."""
|
| 34 |
+
hostname: str = Field(description="Worker hostname")
|
| 35 |
+
gpu_count: int = Field(default=0, description="Number of GPUs")
|
| 36 |
+
gpu_info: Optional[List[GPUInfo]] = Field(default=None, description="GPU information")
|
| 37 |
+
gpu_memory_total: int = Field(default=0, description="Total GPU memory in MB")
|
| 38 |
+
max_concurrent_jobs: int = Field(default=1, description="Max concurrent jobs")
|
| 39 |
+
capabilities: Optional[Dict[str, Any]] = Field(
|
| 40 |
+
default=None,
|
| 41 |
+
description="Worker capabilities (supported models, etc.)"
|
| 42 |
+
)
|
| 43 |
+
worker_metadata: Optional[Dict[str, Any]] = Field(
|
| 44 |
+
default=None,
|
| 45 |
+
description="Additional worker metadata"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class WorkerRegistrationResponse(BaseModel):
|
| 50 |
+
"""Response model for worker registration."""
|
| 51 |
+
worker_id: str = Field(description="Assigned worker ID")
|
| 52 |
+
status: WorkerStatus = Field(description="Worker status")
|
| 53 |
+
registered_at: datetime = Field(description="Registration timestamp")
|
| 54 |
+
heartbeat_interval: int = Field(
|
| 55 |
+
default=30,
|
| 56 |
+
description="Heartbeat interval in seconds"
|
| 57 |
+
)
|
| 58 |
+
heartbeat_timeout: int = Field(
|
| 59 |
+
default=120,
|
| 60 |
+
description="Heartbeat timeout in seconds (worker marked offline after this)"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class HeartbeatRequest(BaseModel):
|
| 65 |
+
"""Request model for worker heartbeat."""
|
| 66 |
+
worker_id: str = Field(description="Worker ID")
|
| 67 |
+
gpu_usage: Optional[float] = Field(
|
| 68 |
+
default=None,
|
| 69 |
+
description="Current GPU usage percentage"
|
| 70 |
+
)
|
| 71 |
+
gpu_memory_used: Optional[int] = Field(
|
| 72 |
+
default=None,
|
| 73 |
+
description="Current GPU memory used in MB"
|
| 74 |
+
)
|
| 75 |
+
active_jobs: Optional[int] = Field(
|
| 76 |
+
default=None,
|
| 77 |
+
description="Number of active jobs"
|
| 78 |
+
)
|
| 79 |
+
gpu_info: Optional[List[GPUInfo]] = Field(
|
| 80 |
+
default=None,
|
| 81 |
+
description="Updated GPU information"
|
| 82 |
+
)
|
| 83 |
+
status: Optional[WorkerStatus] = Field(
|
| 84 |
+
default=None,
|
| 85 |
+
description="Worker status (optional override)"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class HeartbeatResponse(BaseModel):
|
| 90 |
+
"""Response model for worker heartbeat."""
|
| 91 |
+
worker_id: str = Field(description="Worker ID")
|
| 92 |
+
status: WorkerStatus = Field(description="Worker status")
|
| 93 |
+
timestamp: datetime = Field(description="Heartbeat timestamp")
|
| 94 |
+
assigned_jobs: int = Field(description="Number of assigned jobs")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class WorkerStatusResponse(BaseModel):
|
| 98 |
+
"""Response model for worker status."""
|
| 99 |
+
worker_id: str = Field(description="Worker ID")
|
| 100 |
+
hostname: str = Field(description="Worker hostname")
|
| 101 |
+
status: WorkerStatus = Field(description="Worker status")
|
| 102 |
+
gpu_count: int = Field(description="Number of GPUs")
|
| 103 |
+
gpu_memory_total: int = Field(description="Total GPU memory in MB")
|
| 104 |
+
gpu_memory_used: int = Field(description="Used GPU memory in MB")
|
| 105 |
+
gpu_usage_percent: float = Field(description="GPU usage percentage")
|
| 106 |
+
active_jobs: int = Field(description="Number of active jobs")
|
| 107 |
+
max_concurrent_jobs: int = Field(description="Max concurrent jobs")
|
| 108 |
+
load_factor: float = Field(
|
| 109 |
+
description="Load factor (active_jobs / max_concurrent_jobs)"
|
| 110 |
+
)
|
| 111 |
+
last_heartbeat: datetime = Field(description="Last heartbeat timestamp")
|
| 112 |
+
registered_at: datetime = Field(description="Registration timestamp")
|
| 113 |
+
capabilities: Optional[Dict[str, Any]] = Field(
|
| 114 |
+
default=None,
|
| 115 |
+
description="Worker capabilities"
|
| 116 |
+
)
|
| 117 |
+
uptime_seconds: int = Field(description="Worker uptime in seconds")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class WorkerListResponse(BaseModel):
|
| 121 |
+
"""Response model for listing workers."""
|
| 122 |
+
workers: List[WorkerStatusResponse] = Field(description="List of workers")
|
| 123 |
+
total: int = Field(description="Total number of workers")
|
| 124 |
+
active: int = Field(description="Number of active workers")
|
| 125 |
+
offline: int = Field(description="Number of offline workers")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class WorkerMetricsResponse(BaseModel):
|
| 129 |
+
"""Response model for worker cluster metrics."""
|
| 130 |
+
total_workers: int = Field(description="Total number of workers")
|
| 131 |
+
active_workers: int = Field(description="Number of active workers")
|
| 132 |
+
offline_workers: int = Field(description="Number of offline workers")
|
| 133 |
+
degraded_workers: int = Field(description="Number of degraded workers")
|
| 134 |
+
total_gpus: int = Field(description="Total number of GPUs")
|
| 135 |
+
total_gpu_memory_mb: int = Field(
|
| 136 |
+
description="Total GPU memory in MB"
|
| 137 |
+
)
|
| 138 |
+
used_gpu_memory_mb: int = Field(
|
| 139 |
+
description="Used GPU memory in MB"
|
| 140 |
+
)
|
| 141 |
+
total_active_jobs: int = Field(
|
| 142 |
+
description="Total number of active jobs across all workers"
|
| 143 |
+
)
|
| 144 |
+
average_load_factor: float = Field(
|
| 145 |
+
description="Average load factor across workers"
|
| 146 |
+
)
|
| 147 |
+
queue_length: int = Field(description="Number of pending jobs in queue")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
__all__ = [
|
| 151 |
+
"WorkerStatus",
|
| 152 |
+
"GPUInfo",
|
| 153 |
+
"WorkerRegistrationRequest",
|
| 154 |
+
"WorkerRegistrationResponse",
|
| 155 |
+
"HeartbeatRequest",
|
| 156 |
+
"HeartbeatResponse",
|
| 157 |
+
"WorkerStatusResponse",
|
| 158 |
+
"WorkerListResponse",
|
| 159 |
+
"WorkerMetricsResponse",
|
| 160 |
+
]
|
backend/scheduler/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Intelligent Evaluation Scheduler Module
|
| 3 |
+
|
| 4 |
+
Provides cost-aware job scheduling with:
|
| 5 |
+
- Priority engine for multi-factor job scoring
|
| 6 |
+
- GPU cost modeling and estimation
|
| 7 |
+
- Resource allocation optimization
|
| 8 |
+
- Multi-queue system (High, Medium, Low priority)
|
| 9 |
+
- Tenant budget tracking and enforcement
|
| 10 |
+
- SLA-aware priority boosting
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
# Import and export components from submodules
|
| 14 |
+
from .priority_engine import PriorityEngine, JobPriorityScore
|
| 15 |
+
from .cost_model import CostModel, CostEstimate
|
| 16 |
+
from .resource_allocator import ResourceAllocator, AllocationDecision
|
| 17 |
+
from .scheduling_policy import SchedulingPolicyEngine, PriorityQueueType
|
| 18 |
+
from .usage_tracker import UsageTracker
|
| 19 |
+
|
| 20 |
+
# Module version
|
| 21 |
+
__version__ = "1.0.0"
|
| 22 |
+
|
| 23 |
+
__all__ = [
|
| 24 |
+
"PriorityEngine",
|
| 25 |
+
"JobPriorityScore",
|
| 26 |
+
"CostModel",
|
| 27 |
+
"CostEstimate",
|
| 28 |
+
"ResourceAllocator",
|
| 29 |
+
"AllocationDecision",
|
| 30 |
+
"SchedulingPolicyEngine",
|
| 31 |
+
"PriorityQueueType",
|
| 32 |
+
"UsageTracker",
|
| 33 |
+
]
|
backend/scheduler/cost_model.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GPU Cost Model for Job Estimation
|
| 3 |
+
|
| 4 |
+
Estimates GPU costs for evaluation jobs:
|
| 5 |
+
- Cost = GPUHours × CostPerGPUHour
|
| 6 |
+
- GPUHours = (Samples × AvgInferenceTime) / 3600
|
| 7 |
+
|
| 8 |
+
Supports different GPU types with varying cost per hour.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import uuid
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from enum import Enum
|
| 15 |
+
from typing import Any, Dict, Optional
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class GPUType(str, Enum):
|
| 21 |
+
"""GPU types with different cost profiles."""
|
| 22 |
+
T4 = "t4"
|
| 23 |
+
V100 = "v100"
|
| 24 |
+
A100 = "a100"
|
| 25 |
+
H100 = "h100"
|
| 26 |
+
RTX3090 = "rtx3090"
|
| 27 |
+
RTX4090 = "rtx4090"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Cost per GPU hour (in USD) - based on AWS/GCP spot pricing
|
| 31 |
+
GPU_COST_PER_HOUR = {
|
| 32 |
+
GPUType.T4: 0.526, # AWS g4dn.xlarge spot
|
| 33 |
+
GPUType.V100: 2.48, # AWS p3.2xlarge spot
|
| 34 |
+
GPUType.A100: 3.67, # AWS p4d.24xlarge spot
|
| 35 |
+
GPUType.H100: 6.50, # AWS p5.48xlarge spot (estimated)
|
| 36 |
+
GPUType.RTX3090: 1.50, # Custom pricing
|
| 37 |
+
GPUType.RTX4090: 2.00, # Custom pricing
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class CostEstimate(BaseModel):
|
| 42 |
+
"""Cost estimation for a job."""
|
| 43 |
+
job_id: uuid.UUID
|
| 44 |
+
tenant_id: uuid.UUID
|
| 45 |
+
|
| 46 |
+
# Estimation details
|
| 47 |
+
total_samples: int = 0
|
| 48 |
+
estimated_gpu_hours: float = 0.0
|
| 49 |
+
cost_per_gpu_hour: float = 3.00
|
| 50 |
+
estimated_cost: float = 0.0
|
| 51 |
+
|
| 52 |
+
# Actual (populated after job completes)
|
| 53 |
+
actual_gpu_hours: Optional[float] = None
|
| 54 |
+
actual_cost: Optional[float] = None
|
| 55 |
+
|
| 56 |
+
# Metadata
|
| 57 |
+
gpu_type: str = "v100"
|
| 58 |
+
calculated_at: datetime = None
|
| 59 |
+
|
| 60 |
+
def __init__(self, **data):
|
| 61 |
+
if "calculated_at" not in data or data["calculated_at"] is None:
|
| 62 |
+
data["calculated_at"] = datetime.utcnow()
|
| 63 |
+
super().__init__(**data)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class CostModel:
|
| 67 |
+
"""
|
| 68 |
+
GPU cost estimation model for evaluation jobs.
|
| 69 |
+
|
| 70 |
+
Calculates estimated costs based on:
|
| 71 |
+
- Number of samples to process
|
| 72 |
+
- Average inference time per sample
|
| 73 |
+
- GPU type used
|
| 74 |
+
- Spot vs on-demand pricing
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
# Default inference times (in milliseconds) per model size
|
| 78 |
+
DEFAULT_INFERENCE_TIMES = {
|
| 79 |
+
"7b": 500, # 7 billion params
|
| 80 |
+
"13b": 800, # 13 billion params
|
| 81 |
+
"30b": 1500, # 30 billion params
|
| 82 |
+
"70b": 2500, # 70 billion params
|
| 83 |
+
"default": 500,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def __init__(
|
| 87 |
+
self,
|
| 88 |
+
default_gpu_type: GPUType = GPUType.V100,
|
| 89 |
+
use_spot_pricing: bool = True,
|
| 90 |
+
):
|
| 91 |
+
"""
|
| 92 |
+
Initialize cost model.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
default_gpu_type: Default GPU type for cost estimation
|
| 96 |
+
use_spot_pricing: Whether to use spot pricing (cheaper)
|
| 97 |
+
"""
|
| 98 |
+
self.default_gpu_type = default_gpu_type
|
| 99 |
+
self.use_spot_pricing = use_spot_pricing
|
| 100 |
+
|
| 101 |
+
# Apply spot discount (typically 60-70% off)
|
| 102 |
+
self.spot_discount = 0.65 if use_spot_pricing else 1.0
|
| 103 |
+
|
| 104 |
+
def estimate_cost(
|
| 105 |
+
self,
|
| 106 |
+
job_id: uuid.UUID,
|
| 107 |
+
tenant_id: uuid.UUID,
|
| 108 |
+
total_samples: int,
|
| 109 |
+
model_size: str = "7b",
|
| 110 |
+
gpu_type: Optional[GPUType] = None,
|
| 111 |
+
avg_inference_time_ms: Optional[float] = None,
|
| 112 |
+
) -> CostEstimate:
|
| 113 |
+
"""
|
| 114 |
+
Estimate the cost for a job.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
job_id: Unique job identifier
|
| 118 |
+
tenant_id: Tenant identifier
|
| 119 |
+
total_samples: Number of samples to process
|
| 120 |
+
model_size: Model size (7b, 13b, 30b, 70b)
|
| 121 |
+
gpu_type: GPU type to use (defaults to default_gpu_type)
|
| 122 |
+
avg_inference_time_ms: Custom inference time (overrides model_size)
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
CostEstimate with all cost details
|
| 126 |
+
"""
|
| 127 |
+
# Determine inference time
|
| 128 |
+
if avg_inference_time_ms is None:
|
| 129 |
+
inference_time_ms = self.DEFAULT_INFERENCE_TIMES.get(
|
| 130 |
+
model_size, self.DEFAULT_INFERENCE_TIMES["default"]
|
| 131 |
+
)
|
| 132 |
+
else:
|
| 133 |
+
inference_time_ms = avg_inference_time_ms
|
| 134 |
+
|
| 135 |
+
# Determine GPU type
|
| 136 |
+
actual_gpu_type = gpu_type or self.default_gpu_type
|
| 137 |
+
|
| 138 |
+
# Get base cost per hour
|
| 139 |
+
base_cost = GPU_COST_PER_HOUR.get(
|
| 140 |
+
actual_gpu_type, GPU_COST_PER_HOUR[GPUType.V100]
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Apply spot discount
|
| 144 |
+
cost_per_hour = base_cost * self.spot_discount
|
| 145 |
+
|
| 146 |
+
# Calculate GPU hours
|
| 147 |
+
# GPUHours = (Samples × AvgInferenceTime) / 3600
|
| 148 |
+
avg_time_per_sample_seconds = inference_time_ms / 1000.0
|
| 149 |
+
total_time_seconds = total_samples * avg_time_per_sample_seconds
|
| 150 |
+
gpu_hours = total_time_seconds / 3600.0
|
| 151 |
+
|
| 152 |
+
# Calculate total cost
|
| 153 |
+
estimated_cost = gpu_hours * cost_per_hour
|
| 154 |
+
|
| 155 |
+
return CostEstimate(
|
| 156 |
+
job_id=job_id,
|
| 157 |
+
tenant_id=tenant_id,
|
| 158 |
+
total_samples=total_samples,
|
| 159 |
+
estimated_gpu_hours=gpu_hours,
|
| 160 |
+
cost_per_gpu_hour=cost_per_hour,
|
| 161 |
+
estimated_cost=estimated_cost,
|
| 162 |
+
gpu_type=actual_gpu_type.value,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
def estimate_cost_for_benchmark(
|
| 166 |
+
self,
|
| 167 |
+
job_id: uuid.UUID,
|
| 168 |
+
tenant_id: uuid.UUID,
|
| 169 |
+
dataset_name: str,
|
| 170 |
+
dataset_version: str,
|
| 171 |
+
model_size: str = "7b",
|
| 172 |
+
) -> CostEstimate:
|
| 173 |
+
"""
|
| 174 |
+
Estimate cost for a standard benchmark job.
|
| 175 |
+
|
| 176 |
+
Uses typical dataset sizes for common benchmarks.
|
| 177 |
+
"""
|
| 178 |
+
# Typical dataset sizes
|
| 179 |
+
DATASET_SIZES = {
|
| 180 |
+
"advbench": 500,
|
| 181 |
+
"truthfulqa": 817,
|
| 182 |
+
"harmfulqa": 1000,
|
| 183 |
+
"jailbreak": 500,
|
| 184 |
+
"default": 500,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
total_samples = DATASET_SIZES.get(dataset_name, DATASET_SIZES["default"])
|
| 188 |
+
|
| 189 |
+
return self.estimate_cost(
|
| 190 |
+
job_id=job_id,
|
| 191 |
+
tenant_id=tenant_id,
|
| 192 |
+
total_samples=total_samples,
|
| 193 |
+
model_size=model_size,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
def calculate_efficiency(
|
| 197 |
+
self,
|
| 198 |
+
robustness_insights_generated: int,
|
| 199 |
+
gpu_hours: float,
|
| 200 |
+
) -> float:
|
| 201 |
+
"""
|
| 202 |
+
Calculate economic efficiency metric.
|
| 203 |
+
|
| 204 |
+
Efficiency = RobustnessInsightsGenerated / GPUHours
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
robustness_insights_generated: Number of samples evaluated
|
| 208 |
+
gpu_hours: Total GPU hours consumed
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
Efficiency score (insights per GPU hour)
|
| 212 |
+
"""
|
| 213 |
+
if gpu_hours <= 0:
|
| 214 |
+
return 0.0
|
| 215 |
+
|
| 216 |
+
return robustness_insights_generated / gpu_hours
|
| 217 |
+
|
| 218 |
+
def get_cost_per_1k_samples(
|
| 219 |
+
self,
|
| 220 |
+
model_size: str = "7b",
|
| 221 |
+
gpu_type: Optional[GPUType] = None,
|
| 222 |
+
) -> float:
|
| 223 |
+
"""
|
| 224 |
+
Get estimated cost per 1000 samples.
|
| 225 |
+
|
| 226 |
+
Useful for quick cost estimates.
|
| 227 |
+
"""
|
| 228 |
+
estimate = self.estimate_cost(
|
| 229 |
+
job_id=uuid.uuid4(),
|
| 230 |
+
tenant_id=uuid.uuid4(),
|
| 231 |
+
total_samples=1000,
|
| 232 |
+
model_size=model_size,
|
| 233 |
+
gpu_type=gpu_type,
|
| 234 |
+
)
|
| 235 |
+
return estimate.estimated_cost
|
| 236 |
+
|
| 237 |
+
def get_gpu_cost_breakdown(
|
| 238 |
+
self,
|
| 239 |
+
gpu_type: Optional[GPUType] = None,
|
| 240 |
+
) -> Dict[str, float]:
|
| 241 |
+
"""
|
| 242 |
+
Get detailed cost breakdown for a GPU type.
|
| 243 |
+
"""
|
| 244 |
+
actual_gpu_type = gpu_type or self.default_gpu_type
|
| 245 |
+
base_cost = GPU_COST_PER_HOUR.get(
|
| 246 |
+
actual_gpu_type, GPU_COST_PER_HOUR[GPUType.V100]
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return {
|
| 250 |
+
"gpu_type": actual_gpu_type.value,
|
| 251 |
+
"base_cost_per_hour": base_cost,
|
| 252 |
+
"spot_cost_per_hour": base_cost * self.spot_discount,
|
| 253 |
+
"on_demand_cost_per_hour": base_cost,
|
| 254 |
+
"cost_per_sample_7b": (base_cost * self.spot_discount * 500) / 3600,
|
| 255 |
+
"cost_per_sample_13b": (base_cost * self.spot_discount * 800) / 3600,
|
| 256 |
+
"cost_per_sample_70b": (base_cost * self.spot_discount * 2500) / 3600,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# Global instance
|
| 261 |
+
_cost_model: Optional[CostModel] = None
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def get_cost_model() -> CostModel:
|
| 265 |
+
"""Get or create the global CostModel instance."""
|
| 266 |
+
global _cost_model
|
| 267 |
+
if _cost_model is None:
|
| 268 |
+
_cost_model = CostModel()
|
| 269 |
+
return _cost_model
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
__all__ = [
|
| 273 |
+
"CostModel",
|
| 274 |
+
"CostEstimate",
|
| 275 |
+
"GPUType",
|
| 276 |
+
"GPU_COST_PER_HOUR",
|
| 277 |
+
"get_cost_model",
|
| 278 |
+
]
|
backend/scheduler/priority_engine.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Priority Engine for Intelligent Job Scheduling
|
| 3 |
+
|
| 4 |
+
Computes priority scores for jobs based on multiple factors:
|
| 5 |
+
- Tenant plan weight (Enterprise > Pro > Basic > Free)
|
| 6 |
+
- SLA deadline urgency
|
| 7 |
+
- Cost sensitivity (inverse of budget buffer)
|
| 8 |
+
- Job waiting time (urgency factor)
|
| 9 |
+
|
| 10 |
+
Score = w_p * P + w_s * SLA + w_c * CostSensitivity + w_u * Urgency
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import uuid
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from datetime import datetime, timedelta
|
| 16 |
+
from enum import Enum
|
| 17 |
+
from typing import Any, Dict, Optional
|
| 18 |
+
|
| 19 |
+
from pydantic import BaseModel
|
| 20 |
+
|
| 21 |
+
# Plan type weights as defined in Day 4 requirements
|
| 22 |
+
PLAN_WEIGHTS = {
|
| 23 |
+
"free": 0.2,
|
| 24 |
+
"basic": 0.4,
|
| 25 |
+
"pro": 0.5,
|
| 26 |
+
"enterprise": 1.0,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class PriorityQueueType(str, Enum):
|
| 31 |
+
"""Priority queue types for job classification."""
|
| 32 |
+
HIGH = "high"
|
| 33 |
+
MEDIUM = "medium"
|
| 34 |
+
LOW = "low"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class JobPriorityScore(BaseModel):
|
| 38 |
+
"""Complete priority score breakdown for a job."""
|
| 39 |
+
job_id: uuid.UUID
|
| 40 |
+
tenant_id: uuid.UUID
|
| 41 |
+
|
| 42 |
+
# Component scores (all normalized to [0, 1])
|
| 43 |
+
plan_score: float = 0.0 # Based on tenant plan
|
| 44 |
+
sla_score: float = 0.0 # Based on deadline urgency
|
| 45 |
+
cost_sensitivity_score: float = 0.0 # Based on budget buffer
|
| 46 |
+
urgency_score: float = 0.0 # Based on wait time
|
| 47 |
+
|
| 48 |
+
# Weighted total
|
| 49 |
+
total_score: float = 0.0
|
| 50 |
+
|
| 51 |
+
# Assigned queue
|
| 52 |
+
queue_type: PriorityQueueType = PriorityQueueType.MEDIUM
|
| 53 |
+
|
| 54 |
+
# Metadata
|
| 55 |
+
calculated_at: datetime = None
|
| 56 |
+
estimated_gpu_hours: float = 0.0
|
| 57 |
+
estimated_cost: float = 0.0
|
| 58 |
+
|
| 59 |
+
def __init__(self, **data):
|
| 60 |
+
if "calculated_at" not in data or data["calculated_at"] is None:
|
| 61 |
+
data["calculated_at"] = datetime.utcnow()
|
| 62 |
+
super().__init__(**data)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class PriorityEngine:
|
| 66 |
+
"""
|
| 67 |
+
Multi-factor priority scoring engine for job scheduling.
|
| 68 |
+
|
| 69 |
+
Computes priority scores based on:
|
| 70 |
+
- Tenant plan tier (Enterprise gets scheduling preference)
|
| 71 |
+
- SLA deadline urgency (closer deadlines = higher priority)
|
| 72 |
+
- Cost sensitivity (lower budget buffer = higher priority)
|
| 73 |
+
- Job waiting time (longer waits = higher priority)
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
# Default weights for priority components
|
| 77 |
+
DEFAULT_WEIGHTS = {
|
| 78 |
+
"plan": 0.30, # w_p
|
| 79 |
+
"sla": 0.25, # w_s
|
| 80 |
+
"cost_sensitivity": 0.20, # w_c
|
| 81 |
+
"urgency": 0.25, # w_u
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# Queue score thresholds
|
| 85 |
+
HIGH_QUEUE_THRESHOLD = 0.7
|
| 86 |
+
MEDIUM_QUEUE_THRESHOLD = 0.4
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
weights: Optional[Dict[str, float]] = None,
|
| 91 |
+
avg_inference_time_ms: float = 500.0,
|
| 92 |
+
):
|
| 93 |
+
"""
|
| 94 |
+
Initialize priority engine.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
weights: Custom weights for priority components.
|
| 98 |
+
If None, uses DEFAULT_WEIGHTS.
|
| 99 |
+
avg_inference_time_ms: Average inference time in milliseconds.
|
| 100 |
+
Used for GPU hour estimation.
|
| 101 |
+
"""
|
| 102 |
+
self.weights = weights or self.DEFAULT_WEIGHTS
|
| 103 |
+
self.avg_inference_time_ms = avg_inference_time_ms
|
| 104 |
+
|
| 105 |
+
# Ensure weights sum to 1.0
|
| 106 |
+
total = sum(self.weights.values())
|
| 107 |
+
if total != 1.0:
|
| 108 |
+
# Normalize weights
|
| 109 |
+
self.weights = {k: v / total for k, v in self.weights.items()}
|
| 110 |
+
|
| 111 |
+
def calculate_priority_score(
|
| 112 |
+
self,
|
| 113 |
+
job_id: uuid.UUID,
|
| 114 |
+
tenant_id: uuid.UUID,
|
| 115 |
+
plan_type: str,
|
| 116 |
+
total_samples: int,
|
| 117 |
+
deadline_timestamp: Optional[datetime] = None,
|
| 118 |
+
budget_limit: Optional[float] = None,
|
| 119 |
+
used_budget: Optional[float] = None,
|
| 120 |
+
submitted_at: Optional[datetime] = None,
|
| 121 |
+
current_time: Optional[datetime] = None,
|
| 122 |
+
) -> JobPriorityScore:
|
| 123 |
+
"""
|
| 124 |
+
Calculate comprehensive priority score for a job.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
job_id: Unique job identifier
|
| 128 |
+
tenant_id: Tenant identifier
|
| 129 |
+
plan_type: Tenant plan type (free, basic, pro, enterprise)
|
| 130 |
+
total_samples: Number of samples to process
|
| 131 |
+
deadline_timestamp: Optional deadline for SLA enforcement
|
| 132 |
+
budget_limit: Optional budget limit for the tenant
|
| 133 |
+
used_budget: Amount of budget already used
|
| 134 |
+
submitted_at: When the job was submitted
|
| 135 |
+
current_time: Current timestamp (defaults to now)
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
JobPriorityScore with all component scores and total
|
| 139 |
+
"""
|
| 140 |
+
now = current_time or datetime.utcnow()
|
| 141 |
+
|
| 142 |
+
# 1. Calculate plan score (based on tenant tier)
|
| 143 |
+
plan_score = self._calculate_plan_score(plan_type)
|
| 144 |
+
|
| 145 |
+
# 2. Calculate SLA score (based on deadline urgency)
|
| 146 |
+
sla_score = self._calculate_sla_score(deadline_timestamp, now)
|
| 147 |
+
|
| 148 |
+
# 3. Calculate cost sensitivity (based on budget buffer)
|
| 149 |
+
cost_sensitivity = self._calculate_cost_sensitivity(
|
| 150 |
+
budget_limit, used_budget
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# 4. Calculate urgency score (based on wait time)
|
| 154 |
+
urgency_score = self._calculate_urgency_score(submitted_at, now)
|
| 155 |
+
|
| 156 |
+
# Calculate weighted total
|
| 157 |
+
total_score = (
|
| 158 |
+
self.weights["plan"] * plan_score +
|
| 159 |
+
self.weights["sla"] * sla_score +
|
| 160 |
+
self.weights["cost_sensitivity"] * cost_sensitivity +
|
| 161 |
+
self.weights["urgency"] * urgency_score
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# Determine queue type
|
| 165 |
+
queue_type = self._determine_queue_type(total_score)
|
| 166 |
+
|
| 167 |
+
# Estimate GPU hours and cost
|
| 168 |
+
estimated_gpu_hours = self._estimate_gpu_hours(total_samples)
|
| 169 |
+
estimated_cost = self._estimate_job_cost(estimated_gpu_hours)
|
| 170 |
+
|
| 171 |
+
return JobPriorityScore(
|
| 172 |
+
job_id=job_id,
|
| 173 |
+
tenant_id=tenant_id,
|
| 174 |
+
plan_score=plan_score,
|
| 175 |
+
sla_score=sla_score,
|
| 176 |
+
cost_sensitivity_score=cost_sensitivity,
|
| 177 |
+
urgency_score=urgency_score,
|
| 178 |
+
total_score=total_score,
|
| 179 |
+
queue_type=queue_type,
|
| 180 |
+
calculated_at=now,
|
| 181 |
+
estimated_gpu_hours=estimated_gpu_hours,
|
| 182 |
+
estimated_cost=estimated_cost,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
def _calculate_plan_score(self, plan_type: str) -> float:
|
| 186 |
+
"""
|
| 187 |
+
Calculate score based on tenant plan type.
|
| 188 |
+
|
| 189 |
+
Returns normalized score based on PLAN_WEIGHTS.
|
| 190 |
+
"""
|
| 191 |
+
return PLAN_WEIGHTS.get(plan_type.lower(), PLAN_WEIGHTS["free"])
|
| 192 |
+
|
| 193 |
+
def _calculate_sla_score(
|
| 194 |
+
self,
|
| 195 |
+
deadline_timestamp: Optional[datetime],
|
| 196 |
+
current_time: datetime,
|
| 197 |
+
) -> float:
|
| 198 |
+
"""
|
| 199 |
+
Calculate SLA urgency score.
|
| 200 |
+
|
| 201 |
+
Jobs with imminent deadlines get higher priority.
|
| 202 |
+
If no deadline, returns 0.5 (medium priority).
|
| 203 |
+
"""
|
| 204 |
+
if deadline_timestamp is None:
|
| 205 |
+
return 0.5 # No deadline = medium priority
|
| 206 |
+
|
| 207 |
+
time_remaining = deadline_timestamp - current_time
|
| 208 |
+
|
| 209 |
+
if time_remaining.total_seconds() <= 0:
|
| 210 |
+
# Deadline passed - maximum urgency
|
| 211 |
+
return 1.0
|
| 212 |
+
|
| 213 |
+
# Score decreases as more time remains
|
| 214 |
+
# Map to [0, 1] where 1 is urgent (very little time)
|
| 215 |
+
max_urgency_hours = 1.0 # 1 hour = maximum urgency
|
| 216 |
+
min_urgency_hours = 24.0 # 24 hours = minimum urgency
|
| 217 |
+
|
| 218 |
+
hours_remaining = time_remaining.total_seconds() / 3600
|
| 219 |
+
|
| 220 |
+
if hours_remaining <= max_urgency_hours:
|
| 221 |
+
return 1.0
|
| 222 |
+
elif hours_remaining >= min_urgency_hours:
|
| 223 |
+
return 0.0
|
| 224 |
+
else:
|
| 225 |
+
# Linear interpolation
|
| 226 |
+
return 1.0 - (hours_remaining - max_urgency_hours) / (min_urgency_hours - max_urgency_hours)
|
| 227 |
+
|
| 228 |
+
def _calculate_cost_sensitivity(
|
| 229 |
+
self,
|
| 230 |
+
budget_limit: Optional[float],
|
| 231 |
+
used_budget: Optional[float],
|
| 232 |
+
) -> float:
|
| 233 |
+
"""
|
| 234 |
+
Calculate cost sensitivity based on budget buffer.
|
| 235 |
+
|
| 236 |
+
Returns higher score when budget is nearly exhausted.
|
| 237 |
+
"""
|
| 238 |
+
if budget_limit is None or used_budget is None:
|
| 239 |
+
return 0.5 # No budget tracking = medium sensitivity
|
| 240 |
+
|
| 241 |
+
if budget_limit <= 0:
|
| 242 |
+
return 1.0 # No budget = maximum sensitivity
|
| 243 |
+
|
| 244 |
+
# Calculate buffer remaining
|
| 245 |
+
buffer_remaining = budget_limit - used_budget
|
| 246 |
+
buffer_percent = buffer_remaining / budget_limit
|
| 247 |
+
|
| 248 |
+
if buffer_percent <= 0:
|
| 249 |
+
return 1.0 # Budget exhausted = maximum sensitivity
|
| 250 |
+
elif buffer_percent >= 1.0:
|
| 251 |
+
return 0.0 # Full budget = no sensitivity
|
| 252 |
+
else:
|
| 253 |
+
# Higher sensitivity as buffer decreases
|
| 254 |
+
return 1.0 - buffer_percent
|
| 255 |
+
|
| 256 |
+
def _calculate_urgency_score(
|
| 257 |
+
self,
|
| 258 |
+
submitted_at: Optional[datetime],
|
| 259 |
+
current_time: datetime,
|
| 260 |
+
) -> float:
|
| 261 |
+
"""
|
| 262 |
+
Calculate urgency based on job waiting time.
|
| 263 |
+
|
| 264 |
+
Jobs waiting longer get higher priority to prevent starvation.
|
| 265 |
+
"""
|
| 266 |
+
if submitted_at is None:
|
| 267 |
+
return 0.5 # No submission time = medium urgency
|
| 268 |
+
|
| 269 |
+
wait_time = current_time - submitted_at
|
| 270 |
+
wait_hours = wait_time.total_seconds() / 3600
|
| 271 |
+
|
| 272 |
+
# Maximum urgency after 4 hours of waiting
|
| 273 |
+
# Minimum urgency (0) for jobs submitted < 5 minutes ago
|
| 274 |
+
if wait_hours >= 4.0:
|
| 275 |
+
return 1.0
|
| 276 |
+
elif wait_hours <= 0.083: # 5 minutes
|
| 277 |
+
return 0.0
|
| 278 |
+
else:
|
| 279 |
+
# Linear interpolation
|
| 280 |
+
return (wait_hours - 0.083) / (4.0 - 0.083)
|
| 281 |
+
|
| 282 |
+
def _determine_queue_type(self, total_score: float) -> PriorityQueueType:
|
| 283 |
+
"""Determine which priority queue a job belongs to."""
|
| 284 |
+
if total_score >= self.HIGH_QUEUE_THRESHOLD:
|
| 285 |
+
return PriorityQueueType.HIGH
|
| 286 |
+
elif total_score >= self.MEDIUM_QUEUE_THRESHOLD:
|
| 287 |
+
return PriorityQueueType.MEDIUM
|
| 288 |
+
else:
|
| 289 |
+
return PriorityQueueType.LOW
|
| 290 |
+
|
| 291 |
+
def _estimate_gpu_hours(self, total_samples: int) -> float:
|
| 292 |
+
"""
|
| 293 |
+
Estimate GPU hours required for a job.
|
| 294 |
+
|
| 295 |
+
GPUHours = (Samples * AvgInferenceTime) / 3600
|
| 296 |
+
"""
|
| 297 |
+
# Average time per sample in seconds
|
| 298 |
+
avg_time_per_sample_seconds = self.avg_inference_time_ms / 1000.0
|
| 299 |
+
|
| 300 |
+
# Total time in seconds
|
| 301 |
+
total_time_seconds = total_samples * avg_time_per_sample_seconds
|
| 302 |
+
|
| 303 |
+
# Convert to hours
|
| 304 |
+
gpu_hours = total_time_seconds / 3600.0
|
| 305 |
+
|
| 306 |
+
return gpu_hours
|
| 307 |
+
|
| 308 |
+
def _estimate_job_cost(self, estimated_gpu_hours: float) -> float:
|
| 309 |
+
"""
|
| 310 |
+
Estimate cost based on GPU hours.
|
| 311 |
+
|
| 312 |
+
Cost = GPUHours * CostPerGPUHour
|
| 313 |
+
Default: $3.00 per GPU hour (AWS p3.2xlarge spot price)
|
| 314 |
+
"""
|
| 315 |
+
cost_per_gpu_hour = 3.00 # Default cost
|
| 316 |
+
return estimated_gpu_hours * cost_per_gpu_hour
|
| 317 |
+
|
| 318 |
+
def should_promote_priority(
|
| 319 |
+
self,
|
| 320 |
+
current_score: JobPriorityScore,
|
| 321 |
+
current_time: Optional[datetime] = None,
|
| 322 |
+
) -> bool:
|
| 323 |
+
"""
|
| 324 |
+
Check if a job should be promoted to higher priority.
|
| 325 |
+
|
| 326 |
+
Promotes if:
|
| 327 |
+
- Deadline is approaching and job is not in high queue
|
| 328 |
+
- Job has been waiting too long (starvation prevention)
|
| 329 |
+
"""
|
| 330 |
+
now = current_time or datetime.utcnow()
|
| 331 |
+
|
| 332 |
+
# Calculate time since submission
|
| 333 |
+
if current_score.calculated_at:
|
| 334 |
+
wait_time = now - current_score.calculated_at
|
| 335 |
+
wait_hours = wait_time.total_seconds() / 3600
|
| 336 |
+
|
| 337 |
+
# Promote if waiting > 2 hours in low queue
|
| 338 |
+
if (current_score.queue_type == PriorityQueueType.LOW and
|
| 339 |
+
wait_hours > 2.0):
|
| 340 |
+
return True
|
| 341 |
+
|
| 342 |
+
# Promote if SLA is at risk
|
| 343 |
+
if (current_score.sla_score > 0.8 and
|
| 344 |
+
current_score.queue_type != PriorityQueueType.HIGH):
|
| 345 |
+
return True
|
| 346 |
+
|
| 347 |
+
return False
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# Global instance
|
| 351 |
+
_priority_engine: Optional[PriorityEngine] = None
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def get_priority_engine() -> PriorityEngine:
|
| 355 |
+
"""Get or create the global PriorityEngine instance."""
|
| 356 |
+
global _priority_engine
|
| 357 |
+
if _priority_engine is None:
|
| 358 |
+
_priority_engine = PriorityEngine()
|
| 359 |
+
return _priority_engine
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
__all__ = [
|
| 363 |
+
"PriorityEngine",
|
| 364 |
+
"JobPriorityScore",
|
| 365 |
+
"PriorityQueueType",
|
| 366 |
+
"PLAN_WEIGHTS",
|
| 367 |
+
"get_priority_engine",
|
| 368 |
+
]
|
backend/scheduler/resource_allocator.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Resource Allocator for Adaptive GPU Allocation
|
| 3 |
+
|
| 4 |
+
Handles intelligent resource allocation:
|
| 5 |
+
- GPU allocation decisions
|
| 6 |
+
- Node pool selection
|
| 7 |
+
- Spot vs on-demand selection
|
| 8 |
+
- Batch size optimization
|
| 9 |
+
- Inference mode selection
|
| 10 |
+
|
| 11 |
+
Supports adaptive resource allocation based on cluster load.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import uuid
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
from pydantic import BaseModel
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class InferenceMode(str, Enum):
|
| 24 |
+
"""Inference modes with different resource requirements."""
|
| 25 |
+
LIGHTWEIGHT = "lightweight" # Fast, less accurate
|
| 26 |
+
STANDARD = "standard" # Balanced
|
| 27 |
+
FULL = "full" # Complete, more accurate
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class NodePoolType(str, Enum):
|
| 31 |
+
"""Node pool types for different workloads."""
|
| 32 |
+
CPU = "cpu"
|
| 33 |
+
GPU_STANDARD = "gpu_standard"
|
| 34 |
+
GPU_HIGH_MEMORY = "gpu_high_memory"
|
| 35 |
+
GPU_AMPERE = "gpu_ampere"
|
| 36 |
+
GPU_HOPPER = "gpu_hopper"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class AllocationDecision(BaseModel):
|
| 40 |
+
"""Resource allocation decision for a job."""
|
| 41 |
+
job_id: uuid.UUID
|
| 42 |
+
|
| 43 |
+
# Allocation details
|
| 44 |
+
gpu_count: int = 1
|
| 45 |
+
gpu_type: str = "v100"
|
| 46 |
+
node_pool: str = "gpu_standard"
|
| 47 |
+
inference_mode: str = "standard"
|
| 48 |
+
batch_size: int = 4
|
| 49 |
+
|
| 50 |
+
# Spot vs on-demand
|
| 51 |
+
use_spot: bool = True
|
| 52 |
+
|
| 53 |
+
# Optimization hints
|
| 54 |
+
enable_batching: bool = False
|
| 55 |
+
reduce_mutation_depth: bool = False
|
| 56 |
+
use_lightweight_hallucination: bool = False
|
| 57 |
+
|
| 58 |
+
# Metadata
|
| 59 |
+
allocated_at: datetime = None
|
| 60 |
+
|
| 61 |
+
def __init__(self, **data):
|
| 62 |
+
if "allocated_at" not in data or data["allocated_at"] is None:
|
| 63 |
+
data["allocated_at"] = datetime.utcnow()
|
| 64 |
+
super().__init__(**data)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ResourceAllocator:
|
| 68 |
+
"""
|
| 69 |
+
Intelligent resource allocator for evaluation jobs.
|
| 70 |
+
|
| 71 |
+
Makes allocation decisions based on:
|
| 72 |
+
- Job requirements (GPU count, memory)
|
| 73 |
+
- Cluster state (available resources)
|
| 74 |
+
- Cost optimization (spot vs on-demand)
|
| 75 |
+
- Priority (high priority = better resources)
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
# Default configurations
|
| 79 |
+
DEFAULT_BATCH_SIZE = 4
|
| 80 |
+
DEFAULT_GPU_COUNT = 1
|
| 81 |
+
|
| 82 |
+
# Node pool specifications
|
| 83 |
+
NODE_POOL_SPECS = {
|
| 84 |
+
NodePoolType.CPU: {
|
| 85 |
+
"gpu_count": 0,
|
| 86 |
+
"memory_gb": 32,
|
| 87 |
+
"cost_per_hour": 0.50,
|
| 88 |
+
},
|
| 89 |
+
NodePoolType.GPU_STANDARD: {
|
| 90 |
+
"gpu_count": 1,
|
| 91 |
+
"gpu_type": "v100",
|
| 92 |
+
"memory_gb": 60,
|
| 93 |
+
"cost_per_hour": 2.48,
|
| 94 |
+
},
|
| 95 |
+
NodePoolType.GPU_HIGH_MEMORY: {
|
| 96 |
+
"gpu_count": 1,
|
| 97 |
+
"gpu_type": "v100",
|
| 98 |
+
"memory_gb": 120,
|
| 99 |
+
"cost_per_hour": 3.50,
|
| 100 |
+
},
|
| 101 |
+
NodePoolType.GPU_AMPERE: {
|
| 102 |
+
"gpu_count": 1,
|
| 103 |
+
"gpu_type": "a100",
|
| 104 |
+
"memory_gb": 80,
|
| 105 |
+
"cost_per_hour": 3.67,
|
| 106 |
+
},
|
| 107 |
+
NodePoolType.GPU_HOPPER: {
|
| 108 |
+
"gpu_count": 1,
|
| 109 |
+
"gpu_type": "h100",
|
| 110 |
+
"memory_gb": 160,
|
| 111 |
+
"cost_per_hour": 6.50,
|
| 112 |
+
},
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
def __init__(
|
| 116 |
+
self,
|
| 117 |
+
default_node_pool: NodePoolType = NodePoolType.GPU_STANDARD,
|
| 118 |
+
enable_spot_by_default: bool = True,
|
| 119 |
+
):
|
| 120 |
+
"""
|
| 121 |
+
Initialize resource allocator.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
default_node_pool: Default node pool type
|
| 125 |
+
enable_spot_by_default: Use spot instances by default
|
| 126 |
+
"""
|
| 127 |
+
self.default_node_pool = default_node_pool
|
| 128 |
+
self.enable_spot_by_default = enable_spot_by_default
|
| 129 |
+
|
| 130 |
+
# Cluster state (would be updated from monitoring)
|
| 131 |
+
self._cluster_load: float = 0.0
|
| 132 |
+
self._available_gpu_count: int = 0
|
| 133 |
+
|
| 134 |
+
def allocate_resources(
|
| 135 |
+
self,
|
| 136 |
+
job_id: uuid.UUID,
|
| 137 |
+
total_samples: int,
|
| 138 |
+
priority_score: float = 0.5,
|
| 139 |
+
required_gpu_memory_mb: int = 0,
|
| 140 |
+
model_size: str = "7b",
|
| 141 |
+
cluster_load: Optional[float] = None,
|
| 142 |
+
available_gpus: Optional[int] = None,
|
| 143 |
+
) -> AllocationDecision:
|
| 144 |
+
"""
|
| 145 |
+
Determine resource allocation for a job.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
job_id: Unique job identifier
|
| 149 |
+
total_samples: Number of samples to process
|
| 150 |
+
priority_score: Job priority score (0-1)
|
| 151 |
+
required_gpu_memory_mb: Required GPU memory in MB
|
| 152 |
+
model_size: Model size (7b, 13b, 30b, 70b)
|
| 153 |
+
cluster_load: Current cluster load (0-1)
|
| 154 |
+
available_gpus: Number of available GPUs
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
AllocationDecision with resource allocation details
|
| 158 |
+
"""
|
| 159 |
+
# Use provided cluster state or defaults
|
| 160 |
+
load = cluster_load if cluster_load is not None else self._cluster_load
|
| 161 |
+
gpus = available_gpus if available_gpus is not None else self._available_gpu_count
|
| 162 |
+
|
| 163 |
+
# Determine inference mode based on priority and load
|
| 164 |
+
inference_mode = self._determine_inference_mode(priority_score, load)
|
| 165 |
+
|
| 166 |
+
# Determine batch size
|
| 167 |
+
batch_size = self._determine_batch_size(
|
| 168 |
+
total_samples, load, inference_mode
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Determine GPU type and node pool
|
| 172 |
+
gpu_type, node_pool = self._determine_gpu_and_pool(
|
| 173 |
+
model_size, required_gpu_memory_mb
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Determine spot vs on-demand
|
| 177 |
+
use_spot = self._determine_use_spot(priority_score, load)
|
| 178 |
+
|
| 179 |
+
# Determine optimization flags
|
| 180 |
+
enable_batching = self._should_enable_batching(load, total_samples)
|
| 181 |
+
reduce_mutation_depth = self._should_reduce_mutation_depth(
|
| 182 |
+
priority_score, load
|
| 183 |
+
)
|
| 184 |
+
use_lightweight_hallucination = self._should_use_lightweight_hallucination(
|
| 185 |
+
load
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
return AllocationDecision(
|
| 189 |
+
job_id=job_id,
|
| 190 |
+
gpu_count=self.DEFAULT_GPU_COUNT,
|
| 191 |
+
gpu_type=gpu_type,
|
| 192 |
+
node_pool=node_pool.value,
|
| 193 |
+
inference_mode=inference_mode.value,
|
| 194 |
+
batch_size=batch_size,
|
| 195 |
+
use_spot=use_spot,
|
| 196 |
+
enable_batching=enable_batching,
|
| 197 |
+
reduce_mutation_depth=reduce_mutation_depth,
|
| 198 |
+
use_lightweight_hallucination=use_lightweight_hallucination,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
def _determine_inference_mode(
|
| 202 |
+
self,
|
| 203 |
+
priority_score: float,
|
| 204 |
+
cluster_load: float,
|
| 205 |
+
) -> InferenceMode:
|
| 206 |
+
"""Determine inference mode based on priority and load."""
|
| 207 |
+
# High priority jobs get full mode
|
| 208 |
+
if priority_score >= 0.7:
|
| 209 |
+
return InferenceMode.FULL
|
| 210 |
+
|
| 211 |
+
# Low load allows full mode
|
| 212 |
+
if cluster_load < 0.5:
|
| 213 |
+
return InferenceMode.FULL
|
| 214 |
+
|
| 215 |
+
# Medium load - use standard
|
| 216 |
+
if cluster_load < 0.8:
|
| 217 |
+
return InferenceMode.STANDARD
|
| 218 |
+
|
| 219 |
+
# High load - use lightweight
|
| 220 |
+
return InferenceMode.LIGHTWEIGHT
|
| 221 |
+
|
| 222 |
+
def _determine_batch_size(
|
| 223 |
+
self,
|
| 224 |
+
total_samples: int,
|
| 225 |
+
cluster_load: float,
|
| 226 |
+
inference_mode: InferenceMode,
|
| 227 |
+
) -> int:
|
| 228 |
+
"""Determine optimal batch size."""
|
| 229 |
+
if inference_mode == InferenceMode.LIGHTWEIGHT:
|
| 230 |
+
# Lightweight mode allows larger batches
|
| 231 |
+
if cluster_load < 0.5:
|
| 232 |
+
return min(16, max(4, total_samples // 10))
|
| 233 |
+
return min(8, max(2, total_samples // 20))
|
| 234 |
+
|
| 235 |
+
if inference_mode == InferenceMode.FULL:
|
| 236 |
+
# Full mode requires smaller batches
|
| 237 |
+
return min(4, max(1, total_samples // 50))
|
| 238 |
+
|
| 239 |
+
# Standard mode
|
| 240 |
+
return min(8, max(2, total_samples // 25))
|
| 241 |
+
|
| 242 |
+
def _determine_gpu_and_pool(
|
| 243 |
+
self,
|
| 244 |
+
model_size: str,
|
| 245 |
+
required_memory_mb: int,
|
| 246 |
+
) -> tuple[str, NodePoolType]:
|
| 247 |
+
"""Determine GPU type and node pool."""
|
| 248 |
+
# Map model size to requirements
|
| 249 |
+
model_requirements = {
|
| 250 |
+
"7b": {"gpu_type": "v100", "memory_gb": 16},
|
| 251 |
+
"13b": {"gpu_type": "v100", "memory_gb": 30},
|
| 252 |
+
"30b": {"gpu_type": "a100", "memory_gb": 60},
|
| 253 |
+
"70b": {"gpu_type": "a100", "memory_gb": 120},
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
req = model_requirements.get(model_size, model_requirements["7b"])
|
| 257 |
+
|
| 258 |
+
# Check if more memory is required
|
| 259 |
+
if required_memory_mb > req["memory_gb"] * 1024:
|
| 260 |
+
return (req["gpu_type"], NodePoolType.GPU_HIGH_MEMORY)
|
| 261 |
+
|
| 262 |
+
# Select node pool based on GPU type
|
| 263 |
+
if req["gpu_type"] == "h100":
|
| 264 |
+
return (req["gpu_type"], NodePoolType.GPU_HOPPER)
|
| 265 |
+
elif req["gpu_type"] == "a100":
|
| 266 |
+
return (req["gpu_type"], NodePoolType.GPU_AMPERE)
|
| 267 |
+
else:
|
| 268 |
+
return (req["gpu_type"], NodePoolType.GPU_STANDARD)
|
| 269 |
+
|
| 270 |
+
def _determine_use_spot(
|
| 271 |
+
self,
|
| 272 |
+
priority_score: float,
|
| 273 |
+
cluster_load: float,
|
| 274 |
+
) -> bool:
|
| 275 |
+
"""Determine whether to use spot instances."""
|
| 276 |
+
# Don't use spot for critical jobs
|
| 277 |
+
if priority_score >= 0.9:
|
| 278 |
+
return False
|
| 279 |
+
|
| 280 |
+
# Use spot by default if enabled
|
| 281 |
+
if self.enable_spot_by_default:
|
| 282 |
+
# But avoid spot during high load (may get preempted)
|
| 283 |
+
if cluster_load > 0.9:
|
| 284 |
+
return False
|
| 285 |
+
return True
|
| 286 |
+
|
| 287 |
+
return False
|
| 288 |
+
|
| 289 |
+
def _should_enable_batching(
|
| 290 |
+
self,
|
| 291 |
+
cluster_load: float,
|
| 292 |
+
total_samples: int,
|
| 293 |
+
) -> bool:
|
| 294 |
+
"""Determine if batching should be enabled."""
|
| 295 |
+
# Enable for large jobs when load is moderate
|
| 296 |
+
return cluster_load < 0.7 and total_samples > 50
|
| 297 |
+
|
| 298 |
+
def _should_reduce_mutation_depth(
|
| 299 |
+
self,
|
| 300 |
+
priority_score: float,
|
| 301 |
+
cluster_load: float,
|
| 302 |
+
) -> bool:
|
| 303 |
+
"""Determine if mutation depth should be reduced."""
|
| 304 |
+
# Reduce for low priority jobs or high load
|
| 305 |
+
return priority_score < 0.4 or cluster_load > 0.8
|
| 306 |
+
|
| 307 |
+
def _should_use_lightweight_hallucination(
|
| 308 |
+
self,
|
| 309 |
+
cluster_load: float,
|
| 310 |
+
) -> bool:
|
| 311 |
+
"""Determine if lightweight hallucination detection should be used."""
|
| 312 |
+
return cluster_load > 0.85
|
| 313 |
+
|
| 314 |
+
def update_cluster_state(
|
| 315 |
+
self,
|
| 316 |
+
cluster_load: float,
|
| 317 |
+
available_gpu_count: int,
|
| 318 |
+
):
|
| 319 |
+
"""
|
| 320 |
+
Update cluster state for allocation decisions.
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
cluster_load: Current cluster load (0-1)
|
| 324 |
+
available_gpu_count: Number of available GPUs
|
| 325 |
+
"""
|
| 326 |
+
self._cluster_load = cluster_load
|
| 327 |
+
self._available_gpu_count = available_gpu_count
|
| 328 |
+
|
| 329 |
+
def get_allocation_cost_per_hour(
|
| 330 |
+
self,
|
| 331 |
+
allocation: AllocationDecision,
|
| 332 |
+
) -> float:
|
| 333 |
+
"""Calculate hourly cost for an allocation decision."""
|
| 334 |
+
node_spec = self.NODE_POOL_SPECS.get(
|
| 335 |
+
NodePoolType(allocation.node_pool),
|
| 336 |
+
self.NODE_POOL_SPECS[NodePoolType.GPU_STANDARD]
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
base_cost = node_spec.get("cost_per_hour", 2.48)
|
| 340 |
+
|
| 341 |
+
# Apply spot discount if using spot
|
| 342 |
+
if allocation.use_spot:
|
| 343 |
+
base_cost *= 0.35 # ~65% discount
|
| 344 |
+
|
| 345 |
+
return base_cost
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# Global instance
|
| 349 |
+
_resource_allocator: Optional[ResourceAllocator] = None
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def get_resource_allocator() -> ResourceAllocator:
|
| 353 |
+
"""Get or create the global ResourceAllocator instance."""
|
| 354 |
+
global _resource_allocator
|
| 355 |
+
if _resource_allocator is None:
|
| 356 |
+
_resource_allocator = ResourceAllocator()
|
| 357 |
+
return _resource_allocator
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
__all__ = [
|
| 361 |
+
"ResourceAllocator",
|
| 362 |
+
"AllocationDecision",
|
| 363 |
+
"InferenceMode",
|
| 364 |
+
"NodePoolType",
|
| 365 |
+
"get_resource_allocator",
|
| 366 |
+
]
|
backend/scheduler/scheduling_policy.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scheduling Policy Engine for: Multi-Queue Management
|
| 3 |
+
|
| 4 |
+
Implements intelligent scheduling policies:
|
| 5 |
+
- Multi-queue system (High, Medium, Low priority)
|
| 6 |
+
- Queue selection based on priority scores
|
| 7 |
+
- SLA-aware priority boosting
|
| 8 |
+
- Preemption strategy support
|
| 9 |
+
|
| 10 |
+
Workers pull from highest non-empty queue.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import uuid
|
| 14 |
+
from collections import deque
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from datetime import datetime, timedelta
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
from pydantic import BaseModel
|
| 21 |
+
|
| 22 |
+
# Import PriorityQueueType from priority_engine
|
| 23 |
+
from .priority_engine import PriorityQueueType
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class QueueJob(BaseModel):
|
| 27 |
+
"""Job entry in a priority queue."""
|
| 28 |
+
job_id: uuid.UUID
|
| 29 |
+
tenant_id: uuid.UUID
|
| 30 |
+
priority_score: float
|
| 31 |
+
submitted_at: datetime
|
| 32 |
+
deadline: Optional[datetime] = None
|
| 33 |
+
estimated_cost: float = 0.0
|
| 34 |
+
metadata: Dict[str, Any] = {}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class SchedulingPolicyEngine:
|
| 38 |
+
"""
|
| 39 |
+
Multi-queue scheduling policy engine.
|
| 40 |
+
|
| 41 |
+
Manages three priority queues:
|
| 42 |
+
- High: For critical/enterprise jobs
|
| 43 |
+
- Medium: For standard jobs
|
| 44 |
+
- Low: For best-effort jobs
|
| 45 |
+
|
| 46 |
+
Workers always pull from the highest non-empty queue.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
# Queue priority order (highest first)
|
| 50 |
+
QUEUE_PRIORITY_ORDER = [
|
| 51 |
+
PriorityQueueType.HIGH,
|
| 52 |
+
PriorityQueueType.MEDIUM,
|
| 53 |
+
PriorityQueueType.LOW,
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
# SLA boosting thresholds
|
| 57 |
+
SLA_BOOST_THRESHOLD_HOURS = 1.0 # Boost if < 1 hour to deadline
|
| 58 |
+
|
| 59 |
+
# Preemption settings
|
| 60 |
+
ENABLE_PREEMPTION = False # Requires checkpointing support
|
| 61 |
+
|
| 62 |
+
def __init__(
|
| 63 |
+
self,
|
| 64 |
+
max_queue_size_high: int = 1000,
|
| 65 |
+
max_queue_size_medium: int = 5000,
|
| 66 |
+
max_queue_size_low: int = 10000,
|
| 67 |
+
):
|
| 68 |
+
"""
|
| 69 |
+
Initialize scheduling policy engine.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
max_queue_size_high: Maximum jobs in high priority queue
|
| 73 |
+
max_queue_size_medium: Maximum jobs in medium priority queue
|
| 74 |
+
max_queue_size_low: Maximum jobs in low priority queue
|
| 75 |
+
"""
|
| 76 |
+
# Initialize queues as deques
|
| 77 |
+
self._queues: Dict[PriorityQueueType, deque] = {
|
| 78 |
+
PriorityQueueType.HIGH: deque(),
|
| 79 |
+
PriorityQueueType.MEDIUM: deque(),
|
| 80 |
+
PriorityQueueType.LOW: deque(),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Queue size limits
|
| 84 |
+
self._max_sizes = {
|
| 85 |
+
PriorityQueueType.HIGH: max_queue_size_high,
|
| 86 |
+
PriorityQueueType.MEDIUM: max_queue_size_medium,
|
| 87 |
+
PriorityQueueType.LOW: max_queue_size_low,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# Track job locations
|
| 91 |
+
self._job_locations: Dict[uuid.UUID, PriorityQueueType] = {}
|
| 92 |
+
|
| 93 |
+
# Statistics
|
| 94 |
+
self._stats = {
|
| 95 |
+
"jobs_enqueued": 0,
|
| 96 |
+
"jobs_dequeued": 0,
|
| 97 |
+
"jobs_rejected": 0,
|
| 98 |
+
"jobs_boosted": 0,
|
| 99 |
+
"preemptions": 0,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
def enqueue(
|
| 103 |
+
self,
|
| 104 |
+
job_id: uuid.UUID,
|
| 105 |
+
tenant_id: uuid.UUID,
|
| 106 |
+
priority_score: float,
|
| 107 |
+
submitted_at: datetime,
|
| 108 |
+
deadline: Optional[datetime] = None,
|
| 109 |
+
estimated_cost: float = 0.0,
|
| 110 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 111 |
+
) -> bool:
|
| 112 |
+
"""
|
| 113 |
+
Add a job to the appropriate queue based on priority score.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
job_id: Unique job identifier
|
| 117 |
+
tenant_id: Tenant identifier
|
| 118 |
+
priority_score: Computed priority score (0-1)
|
| 119 |
+
submitted_at: When the job was submitted
|
| 120 |
+
deadline: Optional deadline for SLA enforcement
|
| 121 |
+
estimated_cost: Estimated job cost
|
| 122 |
+
metadata: Additional job metadata
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
True if enqueued successfully, False if rejected
|
| 126 |
+
"""
|
| 127 |
+
# Determine queue type based on priority score
|
| 128 |
+
queue_type = self._determine_queue(priority_score)
|
| 129 |
+
|
| 130 |
+
# Check if queue has capacity
|
| 131 |
+
if len(self._queues[queue_type]) >= self._max_sizes[queue_type]:
|
| 132 |
+
# Try to enqueue in lower priority queue
|
| 133 |
+
if queue_type != PriorityQueueType.LOW:
|
| 134 |
+
# Try next lower queue
|
| 135 |
+
lower_queues = [q for q in self.QUEUE_PRIORITY_ORDER
|
| 136 |
+
if q.value > queue_type.value]
|
| 137 |
+
for lower_q in lower_queues:
|
| 138 |
+
if len(self._queues[lower_q]) < self._max_sizes[lower_q]:
|
| 139 |
+
queue_type = lower_q
|
| 140 |
+
break
|
| 141 |
+
else:
|
| 142 |
+
# All queues full
|
| 143 |
+
self._stats["jobs_rejected"] += 1
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
# Create queue entry
|
| 147 |
+
queue_job = QueueJob(
|
| 148 |
+
job_id=job_id,
|
| 149 |
+
tenant_id=tenant_id,
|
| 150 |
+
priority_score=priority_score,
|
| 151 |
+
submitted_at=submitted_at,
|
| 152 |
+
deadline=deadline,
|
| 153 |
+
estimated_cost=estimated_cost,
|
| 154 |
+
metadata=metadata or {},
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Add to queue
|
| 158 |
+
self._queues[queue_type].append(queue_job)
|
| 159 |
+
self._job_locations[job_id] = queue_type
|
| 160 |
+
self._stats["jobs_enqueued"] += 1
|
| 161 |
+
|
| 162 |
+
return True
|
| 163 |
+
|
| 164 |
+
def dequeue(self) -> Optional[QueueJob]:
|
| 165 |
+
"""
|
| 166 |
+
Get the next job from the highest priority non-empty queue.
|
| 167 |
+
|
| 168 |
+
Workers call this to get the next job to process.
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
Next job to process, or None if all queues are empty
|
| 172 |
+
"""
|
| 173 |
+
# Check each queue in priority order
|
| 174 |
+
for queue_type in self.QUEUE_PRIORITY_ORDER:
|
| 175 |
+
queue = self._queues[queue_type]
|
| 176 |
+
|
| 177 |
+
if not queue:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
# Apply SLA boosting - check if any job needs priority boost
|
| 181 |
+
if queue_type != PriorityQueueType.HIGH:
|
| 182 |
+
boosted_job = self._check_and_boost_sla(queue)
|
| 183 |
+
if boosted_job:
|
| 184 |
+
self._stats["jobs_boosted"] += 1
|
| 185 |
+
return boosted_job
|
| 186 |
+
|
| 187 |
+
# Get job from front of queue
|
| 188 |
+
job = queue.popleft()
|
| 189 |
+
del self._job_locations[job.job_id]
|
| 190 |
+
self._stats["jobs_dequeued"] += 1
|
| 191 |
+
|
| 192 |
+
return job
|
| 193 |
+
|
| 194 |
+
return None
|
| 195 |
+
|
| 196 |
+
def _determine_queue(self, priority_score: float) -> PriorityQueueType:
|
| 197 |
+
"""Determine which queue a job belongs in based on priority score."""
|
| 198 |
+
if priority_score >= 0.7:
|
| 199 |
+
return PriorityQueueType.HIGH
|
| 200 |
+
elif priority_score >= 0.4:
|
| 201 |
+
return PriorityQueueType.MEDIUM
|
| 202 |
+
else:
|
| 203 |
+
return PriorityQueueType.LOW
|
| 204 |
+
|
| 205 |
+
def _check_and_boost_sla(self, queue: deque) -> Optional[QueueJob]:
|
| 206 |
+
"""
|
| 207 |
+
Check for SLA-critical jobs and boost them.
|
| 208 |
+
|
| 209 |
+
Returns a boosted job if found, None otherwise.
|
| 210 |
+
"""
|
| 211 |
+
now = datetime.utcnow()
|
| 212 |
+
|
| 213 |
+
# Look for jobs with imminent deadlines
|
| 214 |
+
for i, job in enumerate(queue):
|
| 215 |
+
if job.deadline is not None:
|
| 216 |
+
time_to_deadline = job.deadline - now
|
| 217 |
+
hours_remaining = time_to_deadline.total_seconds() / 3600
|
| 218 |
+
|
| 219 |
+
if hours_remaining < self.SLA_BOOST_THRESHOLD_HOURS:
|
| 220 |
+
# Remove from current position
|
| 221 |
+
queue.remove(job)
|
| 222 |
+
# Create a new job with boosted score
|
| 223 |
+
boosted_job = QueueJob(
|
| 224 |
+
job_id=job.job_id,
|
| 225 |
+
tenant_id=job.tenant_id,
|
| 226 |
+
priority_score=1.0, # Maximum priority
|
| 227 |
+
submitted_at=job.submitted_at,
|
| 228 |
+
deadline=job.deadline,
|
| 229 |
+
estimated_cost=job.estimated_cost,
|
| 230 |
+
metadata={**job.metadata, "sla_boosted": True},
|
| 231 |
+
)
|
| 232 |
+
# Add to high priority queue
|
| 233 |
+
self._queues[PriorityQueueType.HIGH].append(boosted_job)
|
| 234 |
+
self._job_locations[job.job_id] = PriorityQueueType.HIGH
|
| 235 |
+
return boosted_job
|
| 236 |
+
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
def get_queue_size(self, queue_type: PriorityQueueType) -> int:
|
| 240 |
+
"""Get the current size of a queue."""
|
| 241 |
+
return len(self._queues[queue_type])
|
| 242 |
+
|
| 243 |
+
def get_all_queue_sizes(self) -> Dict[str, int]:
|
| 244 |
+
"""Get sizes of all queues."""
|
| 245 |
+
return {
|
| 246 |
+
"high": len(self._queues[PriorityQueueType.HIGH]),
|
| 247 |
+
"medium": len(self._queues[PriorityQueueType.MEDIUM]),
|
| 248 |
+
"low": len(self._queues[PriorityQueueType.LOW]),
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
def get_next_queue_type(self) -> Optional[PriorityQueueType]:
|
| 252 |
+
"""
|
| 253 |
+
Get the highest priority non-empty queue.
|
| 254 |
+
|
| 255 |
+
Useful for workers to know which queue to poll.
|
| 256 |
+
"""
|
| 257 |
+
for queue_type in self.QUEUE_PRIORITY_ORDER:
|
| 258 |
+
if self._queues[queue_type]:
|
| 259 |
+
return queue_type
|
| 260 |
+
return None
|
| 261 |
+
|
| 262 |
+
def get_job_queue(self, job_id: uuid.UUID) -> Optional[PriorityQueueType]:
|
| 263 |
+
"""Get the queue a job is currently in."""
|
| 264 |
+
return self._job_locations.get(job_id)
|
| 265 |
+
|
| 266 |
+
def remove_job(self, job_id: uuid.UUID) -> bool:
|
| 267 |
+
"""Remove a specific job from its queue."""
|
| 268 |
+
queue_type = self._job_locations.get(job_id)
|
| 269 |
+
if queue_type is None:
|
| 270 |
+
return False
|
| 271 |
+
|
| 272 |
+
queue = self._queues[queue_type]
|
| 273 |
+
|
| 274 |
+
# Find and remove the job
|
| 275 |
+
for i, job in enumerate(queue):
|
| 276 |
+
if job.job_id == job_id:
|
| 277 |
+
del queue[i]
|
| 278 |
+
del self._job_locations[job_id]
|
| 279 |
+
return True
|
| 280 |
+
|
| 281 |
+
return False
|
| 282 |
+
|
| 283 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 284 |
+
"""Get scheduling statistics."""
|
| 285 |
+
return {
|
| 286 |
+
**self._stats,
|
| 287 |
+
"queue_sizes": self.get_all_queue_sizes(),
|
| 288 |
+
"total_jobs_pending": sum(len(q) for q in self._queues.values()),
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
def clear_queues(self):
|
| 292 |
+
"""Clear all queues (for testing)."""
|
| 293 |
+
for queue in self._queues.values():
|
| 294 |
+
queue.clear()
|
| 295 |
+
self._job_locations.clear()
|
| 296 |
+
|
| 297 |
+
def should_scale_up(
|
| 298 |
+
self,
|
| 299 |
+
queue_length: int,
|
| 300 |
+
avg_estimated_cost: float,
|
| 301 |
+
scale_up_threshold: float = 100.0,
|
| 302 |
+
) -> bool:
|
| 303 |
+
"""
|
| 304 |
+
Determine if the cluster should scale up.
|
| 305 |
+
|
| 306 |
+
Cost-aware scaling:
|
| 307 |
+
ScaleUp = QueueLength × AvgEstimatedCost > threshold
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
queue_length: Number of pending jobs
|
| 311 |
+
avg_estimated_cost: Average estimated cost per job
|
| 312 |
+
scale_up_threshold: Cost threshold for scaling
|
| 313 |
+
|
| 314 |
+
Returns:
|
| 315 |
+
True if scaling up is recommended
|
| 316 |
+
"""
|
| 317 |
+
total_queue_cost = queue_length * avg_estimated_cost
|
| 318 |
+
return total_queue_cost > scale_up_threshold
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
# Global instance
|
| 322 |
+
_scheduling_policy: Optional[SchedulingPolicyEngine] = None
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def get_scheduling_policy_engine() -> SchedulingPolicyEngine:
|
| 326 |
+
"""Get or create the global SchedulingPolicyEngine instance."""
|
| 327 |
+
global _scheduling_policy
|
| 328 |
+
if _scheduling_policy is None:
|
| 329 |
+
_scheduling_policy = SchedulingPolicyEngine()
|
| 330 |
+
return _scheduling_policy
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
__all__ = [
|
| 334 |
+
"SchedulingPolicyEngine",
|
| 335 |
+
"PriorityQueueType",
|
| 336 |
+
"QueueJob",
|
| 337 |
+
"get_scheduling_policy_engine",
|
| 338 |
+
]
|
backend/scheduler/usage_tracker.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Usage Tracker for Tenant Budget Management
|
| 3 |
+
|
| 4 |
+
Tracks tenant resource usage:
|
| 5 |
+
- Total GPU hours consumed
|
| 6 |
+
- Total cost incurred
|
| 7 |
+
- Budget limit enforcement
|
| 8 |
+
- Rolling billing period tracking
|
| 9 |
+
|
| 10 |
+
Supports budget enforcement policies:
|
| 11 |
+
- REJECT: Reject new jobs when budget exceeded
|
| 12 |
+
- DOWNGRADE: Move to lower priority queue
|
| 13 |
+
- THROTTLE: Force throughput mode
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import uuid
|
| 17 |
+
from collections import defaultdict
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from datetime import datetime, timedelta
|
| 20 |
+
from enum import Enum
|
| 21 |
+
from typing import Any, Dict, List, Optional
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BudgetEnforcementPolicy(str, Enum):
|
| 27 |
+
"""Budget enforcement policies."""
|
| 28 |
+
REJECT = "reject" # Reject new jobs
|
| 29 |
+
DOWNGRADE = "downgrade" # Move to lower priority
|
| 30 |
+
THROTTLE = "throttle" # Force throughput mode
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UsageRecord(BaseModel):
|
| 34 |
+
"""Record of resource usage for a job."""
|
| 35 |
+
job_id: uuid.UUID
|
| 36 |
+
tenant_id: uuid.UUID
|
| 37 |
+
|
| 38 |
+
# Usage metrics
|
| 39 |
+
gpu_hours: float = 0.0
|
| 40 |
+
cost: float = 0.0
|
| 41 |
+
samples_processed: int = 0
|
| 42 |
+
|
| 43 |
+
# Timestamps
|
| 44 |
+
started_at: datetime
|
| 45 |
+
completed_at: Optional[datetime] = None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TenantUsage(BaseModel):
|
| 49 |
+
"""Aggregated usage for a tenant."""
|
| 50 |
+
tenant_id: uuid.UUID
|
| 51 |
+
|
| 52 |
+
# Current period usage
|
| 53 |
+
gpu_hours_current: float = 0.0
|
| 54 |
+
cost_current: float = 0.0
|
| 55 |
+
jobs_completed: int = 0
|
| 56 |
+
samples_processed: int = 0
|
| 57 |
+
|
| 58 |
+
# Budget info
|
| 59 |
+
budget_limit: Optional[float] = None
|
| 60 |
+
budget_used_percent: float = 0.0
|
| 61 |
+
|
| 62 |
+
# Period info
|
| 63 |
+
period_start: datetime
|
| 64 |
+
period_end: datetime
|
| 65 |
+
|
| 66 |
+
# Enforcement
|
| 67 |
+
enforcement_policy: str = "reject"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class UsageTracker:
|
| 71 |
+
"""
|
| 72 |
+
Tracks tenant resource usage and enforces budget limits.
|
| 73 |
+
|
| 74 |
+
Features:
|
| 75 |
+
- Per-tenant usage tracking
|
| 76 |
+
- Rolling billing period (default: 30 days)
|
| 77 |
+
- Budget limit enforcement
|
| 78 |
+
- Usage reporting
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
# Default billing period
|
| 82 |
+
DEFAULT_BILLING_PERIOD_DAYS = 30
|
| 83 |
+
|
| 84 |
+
# Default budget enforcement
|
| 85 |
+
DEFAULT_ENFORCEMENT_POLICY = BudgetEnforcementPolicy.REJECT
|
| 86 |
+
|
| 87 |
+
def __init__(
|
| 88 |
+
self,
|
| 89 |
+
billing_period_days: int = DEFAULT_BILLING_PERIOD_DAYS,
|
| 90 |
+
default_budget_limit: Optional[float] = None,
|
| 91 |
+
default_enforcement: BudgetEnforcementPolicy = DEFAULT_ENFORCEMENT_POLICY,
|
| 92 |
+
):
|
| 93 |
+
"""
|
| 94 |
+
Initialize usage tracker.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
billing_period_days: Rolling billing period in days
|
| 98 |
+
default_budget_limit: Default budget limit for new tenants
|
| 99 |
+
default_enforcement: Default enforcement policy
|
| 100 |
+
"""
|
| 101 |
+
self.billing_period_days = billing_period_days
|
| 102 |
+
self.default_budget_limit = default_budget_limit
|
| 103 |
+
self.default_enforcement = default_enforcement
|
| 104 |
+
|
| 105 |
+
# In-memory storage (would use database in production)
|
| 106 |
+
self._tenant_usage: Dict[uuid.UUID, TenantUsage] = {}
|
| 107 |
+
self._job_records: Dict[uuid.UUID, UsageRecord] = {}
|
| 108 |
+
|
| 109 |
+
# Tenant budget settings
|
| 110 |
+
self._tenant_budgets: Dict[uuid.UUID, Dict[str, Any]] = {}
|
| 111 |
+
|
| 112 |
+
# Initialize default budgets for known tenants
|
| 113 |
+
self._initialize_default_budgets()
|
| 114 |
+
|
| 115 |
+
def _initialize_default_budgets(self):
|
| 116 |
+
"""Initialize default budget configurations."""
|
| 117 |
+
# Default budgets by plan type
|
| 118 |
+
self._plan_defaults = {
|
| 119 |
+
"free": {
|
| 120 |
+
"budget_limit": 10.0, # $10/month
|
| 121 |
+
"enforcement": BudgetEnforcementPolicy.REJECT,
|
| 122 |
+
},
|
| 123 |
+
"basic": {
|
| 124 |
+
"budget_limit": 100.0, # $100/month
|
| 125 |
+
"enforcement": BudgetEnforcementPolicy.DOWNGRADE,
|
| 126 |
+
},
|
| 127 |
+
"pro": {
|
| 128 |
+
"budget_limit": 1000.0, # $1000/month
|
| 129 |
+
"enforcement": BudgetEnforcementPolicy.THROTTLE,
|
| 130 |
+
},
|
| 131 |
+
"enterprise": {
|
| 132 |
+
"budget_limit": None, # No limit
|
| 133 |
+
"enforcement": BudgetEnforcementPolicy.THROTTLE,
|
| 134 |
+
},
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
def register_tenant(
|
| 138 |
+
self,
|
| 139 |
+
tenant_id: uuid.UUID,
|
| 140 |
+
plan_type: str = "free",
|
| 141 |
+
):
|
| 142 |
+
"""
|
| 143 |
+
Register a new tenant with default budget.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
tenant_id: Tenant identifier
|
| 147 |
+
plan_type: Tenant plan type
|
| 148 |
+
"""
|
| 149 |
+
now = datetime.utcnow()
|
| 150 |
+
|
| 151 |
+
# Get plan defaults
|
| 152 |
+
plan_defaults = self._plan_defaults.get(
|
| 153 |
+
plan_type.lower(), self._plan_defaults["free"]
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Create tenant usage record
|
| 157 |
+
self._tenant_usage[tenant_id] = TenantUsage(
|
| 158 |
+
tenant_id=tenant_id,
|
| 159 |
+
period_start=now,
|
| 160 |
+
period_end=now + timedelta(days=self.billing_period_days),
|
| 161 |
+
budget_limit=plan_defaults.get("budget_limit"),
|
| 162 |
+
enforcement_policy=plan_defaults.get(
|
| 163 |
+
"enforcement", BudgetEnforcementPolicy.REJECT
|
| 164 |
+
).value,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Store budget settings
|
| 168 |
+
self._tenant_budgets[tenant_id] = {
|
| 169 |
+
"plan_type": plan_type,
|
| 170 |
+
"budget_limit": plan_defaults.get("budget_limit"),
|
| 171 |
+
"enforcement": plan_defaults.get(
|
| 172 |
+
"enforcement", BudgetEnforcementPolicy.REJECT
|
| 173 |
+
),
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
def record_job_start(
|
| 177 |
+
self,
|
| 178 |
+
job_id: uuid.UUID,
|
| 179 |
+
tenant_id: uuid.UUID,
|
| 180 |
+
):
|
| 181 |
+
"""
|
| 182 |
+
Record the start of a job.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
job_id: Job identifier
|
| 186 |
+
tenant_id: Tenant identifier
|
| 187 |
+
"""
|
| 188 |
+
# Ensure tenant is registered
|
| 189 |
+
if tenant_id not in self._tenant_usage:
|
| 190 |
+
self.register_tenant(tenant_id)
|
| 191 |
+
|
| 192 |
+
# Create usage record
|
| 193 |
+
self._job_records[job_id] = UsageRecord(
|
| 194 |
+
job_id=job_id,
|
| 195 |
+
tenant_id=tenant_id,
|
| 196 |
+
started_at=datetime.utcnow(),
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
def record_job_completion(
|
| 200 |
+
self,
|
| 201 |
+
job_id: uuid.UUID,
|
| 202 |
+
gpu_hours: float,
|
| 203 |
+
cost: float,
|
| 204 |
+
samples_processed: int,
|
| 205 |
+
):
|
| 206 |
+
"""
|
| 207 |
+
Record job completion and update tenant usage.
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
job_id: Job identifier
|
| 211 |
+
gpu_hours: GPU hours consumed
|
| 212 |
+
cost: Total cost
|
| 213 |
+
samples_processed: Number of samples processed
|
| 214 |
+
"""
|
| 215 |
+
record = self._job_records.get(job_id)
|
| 216 |
+
if record is None:
|
| 217 |
+
return
|
| 218 |
+
|
| 219 |
+
# Update record
|
| 220 |
+
record.gpu_hours = gpu_hours
|
| 221 |
+
record.cost = cost
|
| 222 |
+
record.samples_processed = samples_processed
|
| 223 |
+
record.completed_at = datetime.utcnow()
|
| 224 |
+
|
| 225 |
+
# Update tenant usage
|
| 226 |
+
tenant_id = record.tenant_id
|
| 227 |
+
if tenant_id in self._tenant_usage:
|
| 228 |
+
usage = self._tenant_usage[tenant_id]
|
| 229 |
+
usage.gpu_hours_current += gpu_hours
|
| 230 |
+
usage.cost_current += cost
|
| 231 |
+
usage.jobs_completed += 1
|
| 232 |
+
usage.samples_processed += samples_processed
|
| 233 |
+
|
| 234 |
+
# Update budget used percentage
|
| 235 |
+
if usage.budget_limit and usage.budget_limit > 0:
|
| 236 |
+
usage.budget_used_percent = (
|
| 237 |
+
usage.cost_current / usage.budget_limit * 100
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
def check_budget(
|
| 241 |
+
self,
|
| 242 |
+
tenant_id: uuid.UUID,
|
| 243 |
+
estimated_cost: float,
|
| 244 |
+
) -> tuple[bool, Optional[str]]:
|
| 245 |
+
"""
|
| 246 |
+
Check if a job can be submitted within budget.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
tenant_id: Tenant identifier
|
| 250 |
+
estimated_cost: Estimated cost for the job
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
Tuple of (allowed, enforcement_action)
|
| 254 |
+
"""
|
| 255 |
+
# Ensure tenant is registered
|
| 256 |
+
if tenant_id not in self._tenant_usage:
|
| 257 |
+
self.register_tenant(tenant_id)
|
| 258 |
+
|
| 259 |
+
usage = self._tenant_usage[tenant_id]
|
| 260 |
+
|
| 261 |
+
# No budget limit - allow
|
| 262 |
+
if usage.budget_limit is None:
|
| 263 |
+
return True, None
|
| 264 |
+
|
| 265 |
+
# Check if would exceed budget
|
| 266 |
+
projected_total = usage.cost_current + estimated_cost
|
| 267 |
+
|
| 268 |
+
if projected_total <= usage.budget_limit:
|
| 269 |
+
return True, None
|
| 270 |
+
|
| 271 |
+
# Budget exceeded - determine enforcement action
|
| 272 |
+
policy = BudgetEnforcementPolicy(usage.enforcement_policy)
|
| 273 |
+
|
| 274 |
+
if policy == BudgetEnforcementPolicy.REJECT:
|
| 275 |
+
return False, "reject"
|
| 276 |
+
elif policy == BudgetEnforcementPolicy.DOWNGRADE:
|
| 277 |
+
return True, "downgrade"
|
| 278 |
+
else: # THROTTLE
|
| 279 |
+
return True, "throttle"
|
| 280 |
+
|
| 281 |
+
def get_tenant_usage(
|
| 282 |
+
self,
|
| 283 |
+
tenant_id: uuid.UUID,
|
| 284 |
+
) -> Optional[TenantUsage]:
|
| 285 |
+
"""
|
| 286 |
+
Get current usage for a tenant.
|
| 287 |
+
|
| 288 |
+
Args:
|
| 289 |
+
tenant_id: Tenant identifier
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
TenantUsage record or None
|
| 293 |
+
"""
|
| 294 |
+
return self._tenant_usage.get(tenant_id)
|
| 295 |
+
|
| 296 |
+
def get_all_tenant_usage(self) -> List[TenantUsage]:
|
| 297 |
+
"""Get usage for all tenants."""
|
| 298 |
+
return list(self._tenant_usage.values())
|
| 299 |
+
|
| 300 |
+
def update_budget(
|
| 301 |
+
self,
|
| 302 |
+
tenant_id: uuid.UUID,
|
| 303 |
+
budget_limit: float,
|
| 304 |
+
enforcement: BudgetEnforcementPolicy,
|
| 305 |
+
):
|
| 306 |
+
"""
|
| 307 |
+
Update tenant budget settings.
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
tenant_id: Tenant identifier
|
| 311 |
+
budget_limit: New budget limit
|
| 312 |
+
enforcement: Enforcement policy
|
| 313 |
+
"""
|
| 314 |
+
if tenant_id not in self._tenant_usage:
|
| 315 |
+
self.register_tenant(tenant_id)
|
| 316 |
+
|
| 317 |
+
usage = self._tenant_usage[tenant_id]
|
| 318 |
+
usage.budget_limit = budget_limit
|
| 319 |
+
usage.enforcement_policy = enforcement.value
|
| 320 |
+
|
| 321 |
+
# Update percentage
|
| 322 |
+
if budget_limit > 0:
|
| 323 |
+
usage.budget_used_percent = (
|
| 324 |
+
usage.cost_current / budget_limit * 100
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Update settings
|
| 328 |
+
self._tenant_budgets[tenant_id] = {
|
| 329 |
+
"budget_limit": budget_limit,
|
| 330 |
+
"enforcement": enforcement,
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
def reset_usage(
|
| 334 |
+
self,
|
| 335 |
+
tenant_id: uuid.UUID,
|
| 336 |
+
):
|
| 337 |
+
"""
|
| 338 |
+
Reset usage for a tenant (typically monthly).
|
| 339 |
+
|
| 340 |
+
Args:
|
| 341 |
+
tenant_id: Tenant identifier
|
| 342 |
+
"""
|
| 343 |
+
if tenant_id not in self._tenant_usage:
|
| 344 |
+
return
|
| 345 |
+
|
| 346 |
+
now = datetime.utcnow()
|
| 347 |
+
usage = self._tenant_usage[tenant_id]
|
| 348 |
+
|
| 349 |
+
# Reset current period
|
| 350 |
+
usage.gpu_hours_current = 0.0
|
| 351 |
+
usage.cost_current = 0.0
|
| 352 |
+
usage.jobs_completed = 0
|
| 353 |
+
usage.samples_processed = 0
|
| 354 |
+
usage.budget_used_percent = 0.0
|
| 355 |
+
usage.period_start = now
|
| 356 |
+
usage.period_end = now + timedelta(days=self.billing_period_days)
|
| 357 |
+
|
| 358 |
+
def get_cost_metrics(
|
| 359 |
+
self,
|
| 360 |
+
tenant_id: uuid.UUID,
|
| 361 |
+
) -> Dict[str, Any]:
|
| 362 |
+
"""
|
| 363 |
+
Get detailed cost metrics for a tenant.
|
| 364 |
+
|
| 365 |
+
Args:
|
| 366 |
+
tenant_id: Tenant identifier
|
| 367 |
+
|
| 368 |
+
Returns:
|
| 369 |
+
Dictionary with cost metrics
|
| 370 |
+
"""
|
| 371 |
+
usage = self._tenant_usage.get(tenant_id)
|
| 372 |
+
if usage is None:
|
| 373 |
+
return {}
|
| 374 |
+
|
| 375 |
+
# Calculate additional metrics
|
| 376 |
+
avg_cost_per_job = (
|
| 377 |
+
usage.cost_current / usage.jobs_completed
|
| 378 |
+
if usage.jobs_completed > 0 else 0.0
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
avg_cost_per_sample = (
|
| 382 |
+
usage.cost_current / usage.samples_processed
|
| 383 |
+
if usage.samples_processed > 0 else 0.0
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
days_remaining = (
|
| 387 |
+
usage.period_end - datetime.utcnow()
|
| 388 |
+
).total_seconds() / 86400
|
| 389 |
+
|
| 390 |
+
projected_monthly_cost = (
|
| 391 |
+
usage.cost_current /
|
| 392 |
+
max(1, (datetime.utcnow() - usage.period_start).total_seconds() / 86400)
|
| 393 |
+
* 30
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
return {
|
| 397 |
+
"tenant_id": str(tenant_id),
|
| 398 |
+
"gpu_hours": usage.gpu_hours_current,
|
| 399 |
+
"total_cost": usage.cost_current,
|
| 400 |
+
"jobs_completed": usage.jobs_completed,
|
| 401 |
+
"samples_processed": usage.samples_processed,
|
| 402 |
+
"budget_limit": usage.budget_limit,
|
| 403 |
+
"budget_used_percent": usage.budget_used_percent,
|
| 404 |
+
"avg_cost_per_job": avg_cost_per_job,
|
| 405 |
+
"avg_cost_per_sample": avg_cost_per_sample,
|
| 406 |
+
"days_remaining": max(0, days_remaining),
|
| 407 |
+
"projected_monthly_cost": projected_monthly_cost,
|
| 408 |
+
"period_start": usage.period_start.isoformat(),
|
| 409 |
+
"period_end": usage.period_end.isoformat(),
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
def get_efficiency_metric(
|
| 413 |
+
self,
|
| 414 |
+
tenant_id: uuid.UUID,
|
| 415 |
+
) -> float:
|
| 416 |
+
"""
|
| 417 |
+
Calculate economic efficiency: Insights / GPUHours.
|
| 418 |
+
|
| 419 |
+
Args:
|
| 420 |
+
tenant_id: Tenant identifier
|
| 421 |
+
|
| 422 |
+
Returns:
|
| 423 |
+
Efficiency score (insights per GPU hour)
|
| 424 |
+
"""
|
| 425 |
+
usage = self._tenant_usage.get(tenant_id)
|
| 426 |
+
if usage is None or usage.gpu_hours_current <= 0:
|
| 427 |
+
return 0.0
|
| 428 |
+
|
| 429 |
+
return usage.samples_processed / usage.gpu_hours_current
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
# Global instance
|
| 433 |
+
_usage_tracker: Optional[UsageTracker] = None
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def get_usage_tracker() -> UsageTracker:
|
| 437 |
+
"""Get or create the global UsageTracker instance."""
|
| 438 |
+
global _usage_tracker
|
| 439 |
+
if _usage_tracker is None:
|
| 440 |
+
_usage_tracker = UsageTracker()
|
| 441 |
+
return _usage_tracker
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
__all__ = [
|
| 445 |
+
"UsageTracker",
|
| 446 |
+
"UsageRecord",
|
| 447 |
+
"TenantUsage",
|
| 448 |
+
"BudgetEnforcementPolicy",
|
| 449 |
+
"get_usage_tracker",
|
| 450 |
+
]
|