feat(Deployment): Integrate Ansible deployment via PHP deployment pipeline
- Create AnsibleDeployStage using framework's Process module for secure command execution - Integrate AnsibleDeployStage into DeploymentPipelineCommands for production deployments - Add force_deploy flag support in Ansible playbook to override stale locks - Use PHP deployment module as orchestrator (php console.php deploy:production) - Fix ErrorAggregationInitializer to use Environment class instead of $_ENV superglobal Architecture: - BuildStage → AnsibleDeployStage → HealthCheckStage for production - Process module provides timeout, error handling, and output capture - Ansible playbook supports rollback via rollback-git-based.yml - Zero-downtime deployments with health checks
This commit is contained in:
455
src/Application/Api/MachineLearning/MLABTestingController.php
Normal file
455
src/Application/Api/MachineLearning/MLABTestingController.php
Normal file
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Api\MachineLearning;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Version;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\MachineLearning\ModelManagement\ABTestingService;
|
||||
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
|
||||
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ABTestConfig;
|
||||
use App\Framework\OpenApi\Attributes\ApiEndpoint;
|
||||
use App\Framework\OpenApi\Attributes\ApiParameter;
|
||||
use App\Framework\OpenApi\Attributes\ApiRequestBody;
|
||||
use App\Framework\OpenApi\Attributes\ApiResponse;
|
||||
use App\Framework\OpenApi\Attributes\ApiSecurity;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
|
||||
/**
|
||||
* ML A/B Testing API Controller
|
||||
*
|
||||
* RESTful API endpoints for A/B testing machine learning models:
|
||||
* - Start A/B tests
|
||||
* - Get test results
|
||||
* - Generate rollout plans
|
||||
* - Calculate sample sizes
|
||||
*/
|
||||
#[ApiSecurity('bearerAuth')]
|
||||
final readonly class MLABTestingController
|
||||
{
|
||||
public function __construct(
|
||||
private ABTestingService $abTesting,
|
||||
private ModelRegistry $registry
|
||||
) {}
|
||||
|
||||
#[Route(path: '/api/ml/ab-test', method: Method::POST)]
|
||||
#[ApiEndpoint(
|
||||
summary: 'Start A/B test',
|
||||
description: 'Create and start an A/B test comparing two model versions',
|
||||
tags: ['Machine Learning'],
|
||||
)]
|
||||
#[ApiRequestBody(
|
||||
description: 'A/B test configuration',
|
||||
required: true,
|
||||
example: [
|
||||
'model_name' => 'fraud-detector',
|
||||
'version_a' => '1.0.0',
|
||||
'version_b' => '2.0.0',
|
||||
'traffic_split_a' => 0.5,
|
||||
'primary_metric' => 'accuracy',
|
||||
'minimum_improvement' => 0.05,
|
||||
],
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 201,
|
||||
description: 'A/B test created successfully',
|
||||
example: [
|
||||
'test_id' => 'test_123',
|
||||
'model_name' => 'fraud-detector',
|
||||
'version_a' => '1.0.0',
|
||||
'version_b' => '2.0.0',
|
||||
'traffic_split' => [
|
||||
'version_a' => 0.5,
|
||||
'version_b' => 0.5,
|
||||
],
|
||||
'status' => 'running',
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
],
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 400,
|
||||
description: 'Invalid test configuration',
|
||||
)]
|
||||
public function startTest(HttpRequest $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$data = $request->parsedBody->toArray();
|
||||
|
||||
// Validate required fields
|
||||
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
|
||||
return new JsonResult([
|
||||
'error' => 'Missing required fields',
|
||||
'required' => ['model_name', 'version_a', 'version_b'],
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Parse versions
|
||||
$versionA = Version::fromString($data['version_a']);
|
||||
$versionB = Version::fromString($data['version_b']);
|
||||
|
||||
// Verify models exist
|
||||
$metadataA = $this->registry->get($data['model_name'], $versionA);
|
||||
$metadataB = $this->registry->get($data['model_name'], $versionB);
|
||||
|
||||
if ($metadataA === null) {
|
||||
return new JsonResult([
|
||||
'error' => 'Version A not found',
|
||||
'model_name' => $data['model_name'],
|
||||
'version' => $data['version_a'],
|
||||
], Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($metadataB === null) {
|
||||
return new JsonResult([
|
||||
'error' => 'Version B not found',
|
||||
'model_name' => $data['model_name'],
|
||||
'version' => $data['version_b'],
|
||||
], Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Create A/B test config
|
||||
$config = new ABTestConfig(
|
||||
modelName: $data['model_name'],
|
||||
versionA: $versionA,
|
||||
versionB: $versionB,
|
||||
trafficSplitA: (float) ($data['traffic_split_a'] ?? 0.5),
|
||||
primaryMetric: $data['primary_metric'] ?? 'accuracy',
|
||||
minimumImprovement: (float) ($data['minimum_improvement'] ?? 0.05),
|
||||
significanceLevel: (float) ($data['significance_level'] ?? 0.05)
|
||||
);
|
||||
|
||||
// Generate test ID (in production, store in database)
|
||||
$testId = 'test_' . bin2hex(random_bytes(8));
|
||||
|
||||
return new JsonResult([
|
||||
'test_id' => $testId,
|
||||
'model_name' => $config->modelName,
|
||||
'version_a' => $config->versionA->toString(),
|
||||
'version_b' => $config->versionB->toString(),
|
||||
'traffic_split' => [
|
||||
'version_a' => $config->trafficSplitA,
|
||||
'version_b' => 1.0 - $config->trafficSplitA,
|
||||
],
|
||||
'primary_metric' => $config->primaryMetric,
|
||||
'minimum_improvement' => $config->minimumImprovement,
|
||||
'status' => 'running',
|
||||
'description' => $config->getDescription(),
|
||||
'created_at' => date('c'),
|
||||
], Status::CREATED);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResult([
|
||||
'error' => 'Invalid test configuration',
|
||||
'message' => $e->getMessage(),
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/api/ml/ab-test/compare', method: Method::POST)]
|
||||
#[ApiEndpoint(
|
||||
summary: 'Compare model versions',
|
||||
description: 'Compare performance of two model versions and get winner recommendation',
|
||||
tags: ['Machine Learning'],
|
||||
)]
|
||||
#[ApiRequestBody(
|
||||
description: 'Model comparison configuration',
|
||||
required: true,
|
||||
example: [
|
||||
'model_name' => 'fraud-detector',
|
||||
'version_a' => '1.0.0',
|
||||
'version_b' => '2.0.0',
|
||||
'primary_metric' => 'f1_score',
|
||||
'minimum_improvement' => 0.05,
|
||||
],
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 200,
|
||||
description: 'Comparison completed successfully',
|
||||
example: [
|
||||
'winner' => 'B',
|
||||
'statistically_significant' => true,
|
||||
'metrics_difference' => [
|
||||
'accuracy' => 0.07,
|
||||
'f1_score' => 0.08,
|
||||
],
|
||||
'primary_metric_improvement' => 8.5,
|
||||
'recommendation' => 'Version B wins with 8.5% improvement - deploy new version',
|
||||
'summary' => 'Version B significantly outperforms Version A',
|
||||
],
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 404,
|
||||
description: 'Model version not found',
|
||||
)]
|
||||
public function compareVersions(HttpRequest $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$data = $request->parsedBody->toArray();
|
||||
|
||||
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
|
||||
return new JsonResult([
|
||||
'error' => 'Missing required fields',
|
||||
'required' => ['model_name', 'version_a', 'version_b'],
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$versionA = Version::fromString($data['version_a']);
|
||||
$versionB = Version::fromString($data['version_b']);
|
||||
|
||||
$config = new ABTestConfig(
|
||||
modelName: $data['model_name'],
|
||||
versionA: $versionA,
|
||||
versionB: $versionB,
|
||||
trafficSplitA: 0.5,
|
||||
primaryMetric: $data['primary_metric'] ?? 'accuracy',
|
||||
minimumImprovement: (float) ($data['minimum_improvement'] ?? 0.05)
|
||||
);
|
||||
|
||||
// Run comparison
|
||||
$result = $this->abTesting->runTest($config);
|
||||
|
||||
return new JsonResult([
|
||||
'winner' => $result->winner,
|
||||
'statistically_significant' => $result->isStatisticallySignificant,
|
||||
'metrics_difference' => $result->metricsDifference,
|
||||
'primary_metric_improvement' => $result->getPrimaryMetricImprovementPercent(),
|
||||
'recommendation' => $result->recommendation,
|
||||
'summary' => $result->getSummary(),
|
||||
'should_deploy_version_b' => $result->shouldDeployVersionB(),
|
||||
'is_inconclusive' => $result->isInconclusive(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResult([
|
||||
'error' => 'Invalid comparison parameters',
|
||||
'message' => $e->getMessage(),
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/api/ml/ab-test/rollout-plan', method: Method::POST)]
|
||||
#[ApiEndpoint(
|
||||
summary: 'Generate rollout plan',
|
||||
description: 'Generate a gradual rollout plan for deploying a new model version',
|
||||
tags: ['Machine Learning'],
|
||||
)]
|
||||
#[ApiRequestBody(
|
||||
description: 'Rollout configuration',
|
||||
required: true,
|
||||
example: [
|
||||
'model_name' => 'fraud-detector',
|
||||
'current_version' => '1.0.0',
|
||||
'new_version' => '2.0.0',
|
||||
'steps' => 5,
|
||||
],
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 200,
|
||||
description: 'Rollout plan generated successfully',
|
||||
example: [
|
||||
'model_name' => 'fraud-detector',
|
||||
'current_version' => '1.0.0',
|
||||
'new_version' => '2.0.0',
|
||||
'rollout_stages' => [
|
||||
[
|
||||
'stage' => 1,
|
||||
'current_version_traffic' => 80,
|
||||
'new_version_traffic' => 20,
|
||||
],
|
||||
[
|
||||
'stage' => 2,
|
||||
'current_version_traffic' => 60,
|
||||
'new_version_traffic' => 40,
|
||||
],
|
||||
],
|
||||
],
|
||||
)]
|
||||
public function generateRolloutPlan(HttpRequest $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$data = $request->parsedBody->toArray();
|
||||
|
||||
if (!isset($data['model_name'], $data['current_version'], $data['new_version'])) {
|
||||
return new JsonResult([
|
||||
'error' => 'Missing required fields',
|
||||
'required' => ['model_name', 'current_version', 'new_version'],
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$steps = (int) ($data['steps'] ?? 5);
|
||||
|
||||
if ($steps < 2 || $steps > 10) {
|
||||
return new JsonResult([
|
||||
'error' => 'Steps must be between 2 and 10',
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Generate rollout plan
|
||||
$plan = $this->abTesting->generateRolloutPlan($steps);
|
||||
|
||||
// Format response
|
||||
$stages = [];
|
||||
foreach ($plan as $stage => $newVersionTraffic) {
|
||||
$stages[] = [
|
||||
'stage' => $stage,
|
||||
'current_version_traffic' => (int) ((1.0 - $newVersionTraffic) * 100),
|
||||
'new_version_traffic' => (int) ($newVersionTraffic * 100),
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'model_name' => $data['model_name'],
|
||||
'current_version' => $data['current_version'],
|
||||
'new_version' => $data['new_version'],
|
||||
'total_stages' => $steps,
|
||||
'rollout_stages' => $stages,
|
||||
'recommendation' => 'Monitor performance at each stage before proceeding to next',
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResult([
|
||||
'error' => 'Invalid rollout configuration',
|
||||
'message' => $e->getMessage(),
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/api/ml/ab-test/sample-size', method: Method::GET)]
|
||||
#[ApiEndpoint(
|
||||
summary: 'Calculate required sample size',
|
||||
description: 'Calculate the required sample size for statistically significant A/B test',
|
||||
tags: ['Machine Learning'],
|
||||
)]
|
||||
#[ApiParameter(
|
||||
name: 'confidence_level',
|
||||
in: 'query',
|
||||
description: 'Confidence level (0.90, 0.95, 0.99)',
|
||||
required: false,
|
||||
type: 'number',
|
||||
example: 0.95,
|
||||
)]
|
||||
#[ApiParameter(
|
||||
name: 'margin_of_error',
|
||||
in: 'query',
|
||||
description: 'Margin of error (typically 0.01-0.10)',
|
||||
required: false,
|
||||
type: 'number',
|
||||
example: 0.05,
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 200,
|
||||
description: 'Sample size calculated successfully',
|
||||
example: [
|
||||
'required_samples_per_version' => 385,
|
||||
'total_samples_needed' => 770,
|
||||
'confidence_level' => 0.95,
|
||||
'margin_of_error' => 0.05,
|
||||
'recommendation' => 'Collect at least 385 predictions per version',
|
||||
],
|
||||
)]
|
||||
public function calculateSampleSize(HttpRequest $request): JsonResult
|
||||
{
|
||||
$confidenceLevel = (float) ($request->queryParameters['confidence_level'] ?? 0.95);
|
||||
$marginOfError = (float) ($request->queryParameters['margin_of_error'] ?? 0.05);
|
||||
|
||||
// Validate parameters
|
||||
if ($confidenceLevel < 0.5 || $confidenceLevel > 0.99) {
|
||||
return new JsonResult([
|
||||
'error' => 'Confidence level must be between 0.5 and 0.99',
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ($marginOfError < 0.01 || $marginOfError > 0.20) {
|
||||
return new JsonResult([
|
||||
'error' => 'Margin of error must be between 0.01 and 0.20',
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Calculate sample size
|
||||
$samplesPerVersion = $this->abTesting->calculateRequiredSampleSize(
|
||||
$confidenceLevel,
|
||||
$marginOfError
|
||||
);
|
||||
|
||||
return new JsonResult([
|
||||
'required_samples_per_version' => $samplesPerVersion,
|
||||
'total_samples_needed' => $samplesPerVersion * 2,
|
||||
'confidence_level' => $confidenceLevel,
|
||||
'margin_of_error' => $marginOfError,
|
||||
'confidence_level_percent' => ($confidenceLevel * 100) . '%',
|
||||
'margin_of_error_percent' => ($marginOfError * 100) . '%',
|
||||
'recommendation' => "Collect at least {$samplesPerVersion} predictions per version for statistically significant results",
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/api/ml/ab-test/select-version', method: Method::POST)]
|
||||
#[ApiEndpoint(
|
||||
summary: 'Select model version for traffic routing',
|
||||
description: 'Randomly select a model version based on A/B test traffic split configuration',
|
||||
tags: ['Machine Learning'],
|
||||
)]
|
||||
#[ApiRequestBody(
|
||||
description: 'Traffic routing configuration',
|
||||
required: true,
|
||||
example: [
|
||||
'model_name' => 'fraud-detector',
|
||||
'version_a' => '1.0.0',
|
||||
'version_b' => '2.0.0',
|
||||
'traffic_split_a' => 0.8,
|
||||
],
|
||||
)]
|
||||
#[ApiResponse(
|
||||
statusCode: 200,
|
||||
description: 'Version selected successfully',
|
||||
example: [
|
||||
'selected_version' => '2.0.0',
|
||||
'model_name' => 'fraud-detector',
|
||||
'traffic_split' => [
|
||||
'version_a' => 0.8,
|
||||
'version_b' => 0.2,
|
||||
],
|
||||
],
|
||||
)]
|
||||
public function selectVersion(HttpRequest $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$data = $request->parsedBody->toArray();
|
||||
|
||||
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
|
||||
return new JsonResult([
|
||||
'error' => 'Missing required fields',
|
||||
'required' => ['model_name', 'version_a', 'version_b'],
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$versionA = Version::fromString($data['version_a']);
|
||||
$versionB = Version::fromString($data['version_b']);
|
||||
|
||||
$config = new ABTestConfig(
|
||||
modelName: $data['model_name'],
|
||||
versionA: $versionA,
|
||||
versionB: $versionB,
|
||||
trafficSplitA: (float) ($data['traffic_split_a'] ?? 0.5),
|
||||
primaryMetric: 'accuracy'
|
||||
);
|
||||
|
||||
// Select version based on traffic split
|
||||
$selectedVersion = $this->abTesting->selectVersion($config);
|
||||
|
||||
return new JsonResult([
|
||||
'selected_version' => $selectedVersion->toString(),
|
||||
'model_name' => $config->modelName,
|
||||
'traffic_split' => [
|
||||
'version_a' => $config->trafficSplitA,
|
||||
'version_b' => 1.0 - $config->trafficSplitA,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResult([
|
||||
'error' => 'Invalid routing configuration',
|
||||
'message' => $e->getMessage(),
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user