From 4e5b5e2eafcd2a93f745746b89fb9d2500ed5778 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 11 Aug 2025 07:28:02 +0200 Subject: [PATCH 1/5] lint --- README.md | 24 +++++++--- docker-compose.yml | 6 +-- rl.info.yml | 2 +- rl.install | 2 - rl.links.menu.yml | 2 +- rl.module | 2 +- rl.permissions.yml | 2 +- rl.php | 35 +++++++------- rl.routing.yml | 2 +- rl.services.yml | 2 +- scripts/prepare-drupal-lint.sh | 4 +- src/Controller/ExperimentController.php | 7 ++- src/Controller/ReportsController.php | 45 ++++++++--------- .../ExperimentDecoratorInterface.php | 2 +- src/Decorator/ExperimentDecoratorManager.php | 3 +- src/Exception/ExperimentNotFoundException.php | 4 +- src/Registry/ExperimentRegistry.php | 15 +++--- src/Registry/ExperimentRegistryInterface.php | 2 +- src/Service/ExperimentManager.php | 4 +- src/Service/ExperimentManagerInterface.php | 2 +- src/Service/ThompsonCalculator.php | 48 ++++++++++++------- src/Storage/ExperimentDataStorage.php | 15 +++--- .../ExperimentDataStorageInterface.php | 2 +- 23 files changed, 124 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index b945a93..57de4a3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Reinforcement Learning (RL) -Multi-armed bandit experiments in Drupal using Thompson Sampling algorithm for efficient A/B testing that minimizes lost conversions. +Multi-armed bandit experiments in Drupal using Thompson Sampling algorithm for +efficient A/B testing that minimizes lost conversions. ## Features @@ -12,9 +13,14 @@ Multi-armed bandit experiments in Drupal using Thompson Sampling algorithm for e ## How Thompson Sampling Works -Thompson Sampling is a learning-while-doing method. Each visitor triggers the algorithm to "roll the dice" based on learned performance. High-performing variants get larger numbers and show more often, while weak variants still get chances to prove themselves. +Thompson Sampling is a learning-while-doing method. Each visitor triggers the +algorithm to "roll the dice" based on learned performance. High-performing +variants get larger numbers and show more often, while weak variants still get +chances to prove themselves. -Traditional A/B tests waste conversions by showing losing variants for fixed durations. Thompson Sampling shifts traffic to better variants as soon as evidence emerges, saving conversions and reducing testing time. +Traditional A/B tests waste conversions by showing losing variants for fixed +durations. Thompson Sampling shifts traffic to better variants as soon as +evidence emerges, saving conversions and reducing testing time. ## Use Cases @@ -55,7 +61,8 @@ $best_option = $ts_calculator->selectBestArm($scores); ## HTTP Endpoints ### rl.php - High-Performance Endpoint (Recommended) -**For high-volume, low-latency applications, use the direct rl.php endpoint:** +**For high-volume, low-latency applications, use the direct rl.php +endpoint:** ```javascript // Record turns (trials) - when content is viewed @@ -87,7 +94,8 @@ navigator.sendBeacon('/modules/contrib/rl/rl.php', rewardData); ## Related Modules -- [AI Sorting](https://www.drupal.org/project/ai_sorting) - Intelligent content ordering for Drupal Views +- [AI Sorting](https://www.drupal.org/project/ai_sorting) - Intelligent content + ordering for Drupal Views ## Technical Implementation @@ -96,6 +104,8 @@ Full algorithm details available in source code: ## Resources -- [Multi-Armed Bandit Problem](https://en.wikipedia.org/wiki/Multi-armed_bandit) - Wikipedia overview +- [Multi-Armed Bandit Problem](https://en.wikipedia.org/wiki/Multi-armed_bandit) - + Wikipedia overview - [Thompson Sampling Paper](https://www.jstor.org/stable/2332286) - Original research -- [Finite-time Analysis](https://homes.di.unimi.it/~cesa-bianchi/Pubblicazioni/ml-02.pdf) - Mathematical foundations \ No newline at end of file +- [Finite-time Analysis](https://homes.di.unimi.it/~cesa-bianchi/Pubblicazioni/ml-02.pdf) - + Mathematical foundations diff --git a/docker-compose.yml b/docker-compose.yml index bfc552a..091c2a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: working_dir: /src command: bash -c "./scripts/run-drupal-lint.sh" environment: - TARGET_DRUPAL_CORE_VERSION: 10 + TARGET_DRUPAL_CORE_VERSION: 11 volumes: - .:/src @@ -16,7 +16,7 @@ services: working_dir: /src command: bash -c "./scripts/run-drupal-lint-auto-fix.sh" environment: - TARGET_DRUPAL_CORE_VERSION: 10 + TARGET_DRUPAL_CORE_VERSION: 11 volumes: - .:/src @@ -27,6 +27,6 @@ services: command: bash -c "/src/scripts/run-drupal-check.sh" tty: true environment: - DRUPAL_RECOMMENDED_PROJECT: 10.3.x-dev + DRUPAL_RECOMMENDED_PROJECT: 11.2.x-dev volumes: - .:/src diff --git a/rl.info.yml b/rl.info.yml index 6d5fe11..f793db6 100644 --- a/rl.info.yml +++ b/rl.info.yml @@ -4,4 +4,4 @@ description: 'Core API module for tracking multi-armed bandit experiments using core_version_requirement: ^10.3 | ^11 package: Custom dependencies: - - drupal:system \ No newline at end of file + - drupal:system diff --git a/rl.install b/rl.install index 40e0b22..5313199 100644 --- a/rl.install +++ b/rl.install @@ -5,7 +5,6 @@ * Install, update and uninstall functions for the Reinforcement Learning module. */ -use Drupal\Core\Database\Database; use Drupal\Core\Database\SchemaObjectExistsException; use Drupal\Core\Database\SchemaObjectDoesNotExistException; @@ -342,4 +341,3 @@ function rl_schema() { return $schema; } - diff --git a/rl.links.menu.yml b/rl.links.menu.yml index 1fc0429..9b53a0e 100644 --- a/rl.links.menu.yml +++ b/rl.links.menu.yml @@ -3,4 +3,4 @@ rl.reports.experiments: description: 'View reinforcement learning experiments and their statistics.' route_name: rl.reports.experiments parent: system.admin_reports - weight: 10 \ No newline at end of file + weight: 10 diff --git a/rl.module b/rl.module index f1b4058..181a022 100644 --- a/rl.module +++ b/rl.module @@ -26,4 +26,4 @@ function rl_help($route_name, RouteMatchInterface $route_match) { $output .= ''; return $output; } -} \ No newline at end of file +} diff --git a/rl.permissions.yml b/rl.permissions.yml index 9b4b9e1..7a3ac45 100644 --- a/rl.permissions.yml +++ b/rl.permissions.yml @@ -4,4 +4,4 @@ administer reinforcement learning: view reinforcement learning data: title: 'View Reinforcement Learning data' - description: 'Access experiment scores and analytics' \ No newline at end of file + description: 'Access experiment scores and analytics' diff --git a/rl.php b/rl.php index 1e11790..0763b84 100644 --- a/rl.php +++ b/rl.php @@ -3,7 +3,7 @@ /** * @file * Handles RL experiment tracking via AJAX with minimal bootstrap. - * + * * Following the statistics.php architecture for optimal performance. * Updated for Drupal 10/11 compatibility. */ @@ -11,12 +11,12 @@ use Drupal\Core\DrupalKernel; use Symfony\Component\HttpFoundation\Request; -// CRITICAL: Only accept POST requests for security and caching reasons +// CRITICAL: Only accept POST requests for security and caching reasons. $action = filter_input(INPUT_POST, 'action', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $experiment_uuid = filter_input(INPUT_POST, 'experiment_uuid', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $arm_id = filter_input(INPUT_POST, 'arm_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS); -// Validate inputs more strictly +// Validate inputs more strictly. if (!$action || !$experiment_uuid || !in_array($action, ['turn', 'turns', 'reward'])) { http_response_code(400); exit(); @@ -28,7 +28,7 @@ exit(); } -// Catch exceptions when site is not configured or storage fails +// Catch exceptions when site is not configured or storage fails. try { // Assumes module in modules/contrib/rl, so three levels below root. chdir('../../..'); @@ -40,40 +40,40 @@ $kernel->boot(); $container = $kernel->getContainer(); - // Check if experiment is registered + // Check if experiment is registered. $registry = $container->get('rl.experiment_registry'); if (!$registry->isRegistered($experiment_uuid)) { - // Silently ignore unregistered experiments like statistics module + // Silently ignore unregistered experiments like statistics module. exit(); } - // Get the experiment data storage service + // Get the experiment data storage service. $storage = $container->get('rl.experiment_data_storage'); - // Handle the different actions + // Handle the different actions. switch ($action) { case 'turn': - // Validate arm_id for single turn + // Validate arm_id for single turn. if ($arm_id && preg_match('/^[a-zA-Z0-9_-]+$/', $arm_id)) { $storage->recordTurn($experiment_uuid, $arm_id); } break; case 'turns': - // Handle multiple turns with better validation + // Handle multiple turns with better validation. $arm_ids = filter_input(INPUT_POST, 'arm_ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS); if ($arm_ids) { $arm_ids_array = explode(',', $arm_ids); $arm_ids_array = array_map('trim', $arm_ids_array); - - // Validate each arm_id + + // Validate each arm_id. $valid_arm_ids = []; foreach ($arm_ids_array as $aid) { if (preg_match('/^[a-zA-Z0-9_-]+$/', $aid)) { $valid_arm_ids[] = $aid; } } - + if (!empty($valid_arm_ids)) { $storage->recordTurns($experiment_uuid, $valid_arm_ids); } @@ -81,17 +81,16 @@ break; case 'reward': - // Validate arm_id for reward + // Validate arm_id for reward. if ($arm_id && preg_match('/^[a-zA-Z0-9_-]+$/', $arm_id)) { $storage->recordReward($experiment_uuid, $arm_id); } break; } - - // Send success response + + // Send success response. http_response_code(200); - } catch (\Exception $e) { // Do nothing if there is PDO Exception or other failure. -} \ No newline at end of file +} diff --git a/rl.routing.yml b/rl.routing.yml index 12c0281..2a6a2f0 100644 --- a/rl.routing.yml +++ b/rl.routing.yml @@ -49,4 +49,4 @@ rl.reports.experiment_detail: _title: 'RL Experiment Detail' requirements: _permission: 'administer rl experiments' - experiment_uuid: '.+' \ No newline at end of file + experiment_uuid: '.+' diff --git a/rl.services.yml b/rl.services.yml index 648bc10..8d3f80d 100644 --- a/rl.services.yml +++ b/rl.services.yml @@ -18,4 +18,4 @@ services: rl.experiment_decorator_manager: class: Drupal\rl\Decorator\ExperimentDecoratorManager tags: - - { name: service_collector, tag: rl_experiment_decorator, call: addDecorator } \ No newline at end of file + - { name: service_collector, tag: rl_experiment_decorator, call: addDecorator } diff --git a/scripts/prepare-drupal-lint.sh b/scripts/prepare-drupal-lint.sh index e5ff2e1..3849771 100755 --- a/scripts/prepare-drupal-lint.sh +++ b/scripts/prepare-drupal-lint.sh @@ -2,8 +2,8 @@ set -e if [ -z "$TARGET_DRUPAL_CORE_VERSION" ]; then - # default to target Drupal 8, you can override this by setting the secrets value on your github repo - TARGET_DRUPAL_CORE_VERSION=10 + # default to target Drupal 11, you can override this by setting the secrets value on your github repo + TARGET_DRUPAL_CORE_VERSION=11 fi echo "php --version" diff --git a/src/Controller/ExperimentController.php b/src/Controller/ExperimentController.php index 28501bb..31d9998 100644 --- a/src/Controller/ExperimentController.php +++ b/src/Controller/ExperimentController.php @@ -12,7 +12,6 @@ * Controller for RL experiment operations. */ class ExperimentController extends ControllerBase { - /** * The experiment manager service. * @@ -35,8 +34,8 @@ public function __construct(ExperimentManagerInterface $experiment_manager) { */ public static function create(ContainerInterface $container) { return new static( - $container->get('rl.experiment_manager') - ); + $container->get('rl.experiment_manager') + ); } /** @@ -144,4 +143,4 @@ public function getThompsonScores(Request $request, $experiment_uuid) { } } -} \ No newline at end of file +} diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php index 129574f..552a2ad 100644 --- a/src/Controller/ReportsController.php +++ b/src/Controller/ReportsController.php @@ -2,6 +2,7 @@ namespace Drupal\rl\Controller; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Database\Connection; use Drupal\Core\Datetime\DateFormatterInterface; @@ -15,7 +16,6 @@ * Controller for RL experiment reports. */ class ReportsController extends ControllerBase { - /** * The database connection. * @@ -68,11 +68,11 @@ public function __construct(Connection $database, ExperimentDataStorageInterface */ public static function create(ContainerInterface $container) { return new static( - $container->get('database'), - $container->get('rl.experiment_data_storage'), - $container->get('date.formatter'), - $container->get('rl.experiment_decorator_manager') - ); + $container->get('database'), + $container->get('rl.experiment_data_storage'), + $container->get('date.formatter'), + $container->get('rl.experiment_decorator_manager') + ); } /** @@ -104,7 +104,7 @@ public function experimentsOverview() { $experiments = $query->execute()->fetchAll(); foreach ($experiments as $experiment) { - // Count arms for this experiment + // Count arms for this experiment. $arms_count = $this->database->select('rl_arm_data', 'ad') ->condition('experiment_uuid', $experiment->uuid) ->countQuery() @@ -116,13 +116,13 @@ public function experimentsOverview() { ]); $detail_link = Link::fromTextAndUrl($this->t('View details'), $detail_url); - // Format last activity timestamp - use totals_updated if available, otherwise registered_at + // Format last activity timestamp - use totals_updated if available, otherwise registered_at. $last_activity_timestamp = $experiment->totals_updated ?: $experiment->registered_at; - $last_activity = $last_activity_timestamp > 0 - ? $this->dateFormatter->format($last_activity_timestamp, 'short') - : $this->t('Never'); + $last_activity = $last_activity_timestamp > 0 + ? $this->dateFormatter->format($last_activity_timestamp, 'short') + : $this->t('Never'); - // Get decorated experiment name or fallback to UUID + // Get decorated experiment name or fallback to UUID. $experiment_display = $this->decoratorManager->decorateExperiment($experiment->uuid); $experiment_name = $experiment_display ? \Drupal::service('renderer')->renderPlain($experiment_display) : $experiment->uuid; @@ -144,7 +144,7 @@ public function experimentsOverview() { '#caption' => $this->t('All Reinforcement Learning experiments and their statistics.'), ]; - // Add some explanatory text + // Add some explanatory text. $build['#prefix'] = '

' . $this->t('This page shows all active reinforcement learning experiments. Each experiment represents a multi-armed bandit test where different "arms" (options) are being evaluated based on user interactions (turns and rewards).') . '

'; return $build; @@ -160,7 +160,7 @@ public function experimentsOverview() { * A render array. */ public function experimentDetail($experiment_uuid) { - // Get experiment totals + // Get experiment totals. $experiment_totals = $this->database->select('rl_experiment_totals', 'et') ->fields('et') ->condition('experiment_uuid', $experiment_uuid) @@ -168,10 +168,10 @@ public function experimentDetail($experiment_uuid) { ->fetchObject(); if (!$experiment_totals) { - throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException(); + throw new NotFoundHttpException(); } - // Get all arms for this experiment + // Get all arms for this experiment. $arms_query = $this->database->select('rl_arm_data', 'ad') ->fields('ad') ->condition('experiment_uuid', $experiment_uuid) @@ -191,13 +191,14 @@ public function experimentDetail($experiment_uuid) { foreach ($arms as $arm) { $success_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0; - - // Calculate Thompson Sampling score + + // Calculate Thompson Sampling score. $alpha_param = $arm->rewards + 1; $beta_param = ($arm->turns - $arm->rewards) + 1; - $ts_score = $alpha_param / ($alpha_param + $beta_param); // Beta mean as approximation + // Beta mean as approximation. + $ts_score = $alpha_param / ($alpha_param + $beta_param); - // Get decorated arm name or fallback to arm ID + // Get decorated arm name or fallback to arm ID. $arm_display = $this->decoratorManager->decorateArm($experiment_uuid, $arm->arm_id); $arm_name = $arm_display ? \Drupal::service('renderer')->renderPlain($arm_display) : $arm->arm_id; @@ -222,11 +223,11 @@ public function experimentDetail($experiment_uuid) { 'table' => $table, ]; - // Add explanatory text + // Add explanatory text. $build['#prefix'] = '

' . $this->t('This page shows detailed information about a specific reinforcement learning experiment and all its arms (options being tested).') . '

'; $build['#suffix'] = '

' . $this->t('Terms:
Turns: Number of times this arm was presented/tried
Rewards: Number of times this arm received positive feedback
Success Rate: Percentage of turns that resulted in rewards
TS Score: Thompson Sampling expected success rate (higher = more likely to be selected)
First Seen: When this content was first added to the experiment
Last Updated: When this arm last received activity (turns or rewards)') . '

'; return $build; } -} \ No newline at end of file +} diff --git a/src/Decorator/ExperimentDecoratorInterface.php b/src/Decorator/ExperimentDecoratorInterface.php index dd26e57..2786621 100644 --- a/src/Decorator/ExperimentDecoratorInterface.php +++ b/src/Decorator/ExperimentDecoratorInterface.php @@ -31,4 +31,4 @@ public function decorateExperiment(string $uuid): ?array; */ public function decorateArm(string $experiment_uuid, string $arm_id): ?array; -} \ No newline at end of file +} diff --git a/src/Decorator/ExperimentDecoratorManager.php b/src/Decorator/ExperimentDecoratorManager.php index d72b85a..82856b0 100644 --- a/src/Decorator/ExperimentDecoratorManager.php +++ b/src/Decorator/ExperimentDecoratorManager.php @@ -9,7 +9,6 @@ * allowing modules to provide their own decorators. */ class ExperimentDecoratorManager { - /** * Array of decorator services. * @@ -67,4 +66,4 @@ public function decorateArm(string $experiment_uuid, string $arm_id): ?array { return NULL; } -} \ No newline at end of file +} diff --git a/src/Exception/ExperimentNotFoundException.php b/src/Exception/ExperimentNotFoundException.php index d44e8e8..4bccdbd 100644 --- a/src/Exception/ExperimentNotFoundException.php +++ b/src/Exception/ExperimentNotFoundException.php @@ -19,11 +19,11 @@ class ExperimentNotFoundException extends \Exception { * @param \Throwable $previous * The previous throwable used for the exception chaining. */ - public function __construct($experiment_uuid = '', $message = '', $code = 0, \Throwable $previous = NULL) { + public function __construct($experiment_uuid = '', $message = '', $code = 0, ?\Throwable $previous = NULL) { if (empty($message)) { $message = sprintf('Experiment "%s" not found.', $experiment_uuid); } parent::__construct($message, $code, $previous); } -} \ No newline at end of file +} diff --git a/src/Registry/ExperimentRegistry.php b/src/Registry/ExperimentRegistry.php index c9bab49..c3791a0 100644 --- a/src/Registry/ExperimentRegistry.php +++ b/src/Registry/ExperimentRegistry.php @@ -8,7 +8,6 @@ * Service for managing experiment registration. */ class ExperimentRegistry implements ExperimentRegistryInterface { - /** * The database connection. * @@ -31,7 +30,7 @@ public function __construct(Connection $database) { */ public function register(string $uuid, string $module): void { try { - // Use merge to handle duplicate registrations gracefully + // Use merge to handle duplicate registrations gracefully. $this->database->merge('rl_experiment_registry') ->key(['uuid' => $uuid]) ->fields([ @@ -41,7 +40,7 @@ public function register(string $uuid, string $module): void { ->execute(); } catch (\Exception $e) { - // Log error but don't break the page + // Log error but don't break the page. \Drupal::logger('rl')->error('Failed to register experiment @uuid: @message', [ '@uuid' => $uuid, '@message' => $e->getMessage(), @@ -60,11 +59,11 @@ public function isRegistered(string $uuid): bool { ->countQuery() ->execute() ->fetchField(); - + return (bool) $result; } catch (\Exception $e) { - // Return false if table doesn't exist yet + // Return false if table doesn't exist yet. return FALSE; } } @@ -79,7 +78,7 @@ public function getOwner(string $uuid): ?string { ->condition('uuid', $uuid) ->execute() ->fetchField(); - + return $result ?: NULL; } catch (\Exception $e) { @@ -96,7 +95,7 @@ public function getAll(): array { ->fields('r', ['uuid', 'module']) ->execute() ->fetchAllKeyed(); - + return $results; } catch (\Exception $e) { @@ -104,4 +103,4 @@ public function getAll(): array { } } -} \ No newline at end of file +} diff --git a/src/Registry/ExperimentRegistryInterface.php b/src/Registry/ExperimentRegistryInterface.php index c7106d8..9e76c12 100644 --- a/src/Registry/ExperimentRegistryInterface.php +++ b/src/Registry/ExperimentRegistryInterface.php @@ -47,4 +47,4 @@ public function getOwner(string $uuid): ?string; */ public function getAll(): array; -} \ No newline at end of file +} diff --git a/src/Service/ExperimentManager.php b/src/Service/ExperimentManager.php index 4166013..3361ebd 100644 --- a/src/Service/ExperimentManager.php +++ b/src/Service/ExperimentManager.php @@ -3,13 +3,11 @@ namespace Drupal\rl\Service; use Drupal\rl\Storage\ExperimentDataStorageInterface; -use Drupal\rl\Service\ThompsonCalculator; /** * Service for managing reinforcement learning experiments. */ class ExperimentManager implements ExperimentManagerInterface { - /** * The experiment data storage. * @@ -88,4 +86,4 @@ public function getThompsonScores($experiment_uuid) { return $this->tsCalculator->calculateThompsonScores($arms_data); } -} \ No newline at end of file +} diff --git a/src/Service/ExperimentManagerInterface.php b/src/Service/ExperimentManagerInterface.php index 9692e34..1a67fcc 100644 --- a/src/Service/ExperimentManagerInterface.php +++ b/src/Service/ExperimentManagerInterface.php @@ -83,4 +83,4 @@ public function getTotalTurns($experiment_uuid); */ public function getThompsonScores($experiment_uuid); -} \ No newline at end of file +} diff --git a/src/Service/ThompsonCalculator.php b/src/Service/ThompsonCalculator.php index 0a26195..cc5a218 100644 --- a/src/Service/ThompsonCalculator.php +++ b/src/Service/ThompsonCalculator.php @@ -7,7 +7,7 @@ * * Idea in metaphor. * ----------------- - * – Each “arm” is a different coffee blend. + * – Each “arm” is a different coffee blend. * – After every cup we store two counts per blend: * • turns = how many cups were served * • rewards = how many of those cups were rated “good” @@ -27,39 +27,45 @@ * • A fast trick to draw a Beta value is: * X ← Gamma(a+1,1) Y ← Gamma(b+1,1) * return X / (X + Y). - * + * * @see https://arxiv.org/abs/1707.02038 * @see https://dl.acm.org/doi/pdf/10.1145/358407.358414 */ class ThompsonCalculator { - /*──────────────────────── PUBLIC API ────────────────────────*/ /** * Draw one Thompson score for every blend. * - * @param array $arms_data objects with ->turns and ->rewards - * @return array scores keyed by blend id + * @param array $arms_data + * Objects with ->turns and ->rewards. + * + * @return array + * Scores keyed by blend id. */ public function calculateThompsonScores(array $arms_data): array { $scores = []; foreach ($arms_data as $id => $arm) { - $alpha = $arm->rewards + 1; // good ratings + 1 - $beta = ($arm->turns - $arm->rewards) + 1; // bad ratings + 1 + // Good ratings + 1. + $alpha = $arm->rewards + 1; + // Bad ratings + 1. + $beta = ($arm->turns - $arm->rewards) + 1; $scores[$id] = $this->randBeta($alpha, $beta); } return $scores; } - /** Pick the blend with the highest score. */ + /** + * Pick the blend with the highest score. */ public function selectBestArm(array $scores) { return $scores ? array_keys($scores, max($scores))[0] : NULL; } /*───────────────────── PRIVATE HELPERS ─────────────────────*/ - /** Draw Beta(α,β) by dividing two Gamma draws. */ + /** + * Draw Beta(α,β) by dividing two Gamma draws. */ private function randBeta(int $alpha, int $beta): float { $x = $this->randGamma($alpha); $y = $this->randGamma($beta); @@ -103,21 +109,25 @@ private function randGamma(float $k): float { } /* ----- Case k ≥ 1 --------------------------------------------- */ - $d = $k - 1.0 / 3.0; // shifts the shape - $c = 1.0 / sqrt(9.0 * $d); // scales the normal draw + // Shifts the shape. + $d = $k - 1.0 / 3.0; + // Scales the normal draw. + $c = 1.0 / sqrt(9.0 * $d); - while (true) { + while (TRUE) { /* Step 1: Z ~ N(0,1) */ $z = $this->z(); /* Step 2: candidate V; (1 + cZ) might be negative if Z < −1/c */ $v = pow(1.0 + $c * $z, 3); - if ($v <= 0.0) { // invalid candidate → try again + // Invalid candidate → try again. + if ($v <= 0.0) { continue; } - $u = $this->u(); // U ~ Uniform(0,1) for accept/reject + // U ~ Uniform(0,1) for accept/reject. + $u = $this->u(); /* Step 3a: FAST acceptance (“squeeze” region) * * Inequality derived in the paper ensures we are safely inside * @@ -138,13 +148,15 @@ private function randGamma(float $k): float { /*──────────── RNG helpers (uniform & normal) ──────────────*/ - /** Uniform(0,1) using PHP’s cryptographic RNG. */ + /** + * Uniform(0,1) using PHP’s cryptographic RNG. */ private function u(): float { return random_int(1, PHP_INT_MAX - 1) / PHP_INT_MAX; } /** * Standard normal N(0,1) via Box-Muller transform. + * * Generates two normals per two uniforms; caches one for speed. */ private function z(): float { @@ -163,8 +175,10 @@ private function z(): float { } while ($s >= 1.0 || $s == 0.0); $factor = sqrt(-2.0 * log($s) / $s); - $cache = $u1 * $factor; // save one normal for next call - return $u2 * $factor; // return the other + // Save one normal for next call. + $cache = $u1 * $factor; + // Return the other. + return $u2 * $factor; } } diff --git a/src/Storage/ExperimentDataStorage.php b/src/Storage/ExperimentDataStorage.php index 38ea853..444497b 100644 --- a/src/Storage/ExperimentDataStorage.php +++ b/src/Storage/ExperimentDataStorage.php @@ -8,7 +8,6 @@ * Storage handler for experiment data. */ class ExperimentDataStorage implements ExperimentDataStorageInterface { - /** * The database connection. * @@ -31,8 +30,8 @@ public function __construct(Connection $database) { */ public function recordTurn($experiment_uuid, $arm_id) { $timestamp = \Drupal::time()->getRequestTime(); - - // Update arm data + + // Update arm data. $this->database->merge('rl_arm_data') ->key(['experiment_uuid' => $experiment_uuid, 'arm_id' => $arm_id]) ->fields([ @@ -44,7 +43,7 @@ public function recordTurn($experiment_uuid, $arm_id) { ->expression('updated', ':timestamp', [':timestamp' => $timestamp]) ->execute(); - // Update total turns + // Update total turns. $this->database->merge('rl_experiment_totals') ->key(['experiment_uuid' => $experiment_uuid]) ->fields([ @@ -71,7 +70,7 @@ public function recordTurns($experiment_uuid, array $arm_ids) { */ public function recordReward($experiment_uuid, $arm_id) { $timestamp = \Drupal::time()->getRequestTime(); - + $this->database->merge('rl_arm_data') ->key(['experiment_uuid' => $experiment_uuid, 'arm_id' => $arm_id]) ->fields([ @@ -82,8 +81,8 @@ public function recordReward($experiment_uuid, $arm_id) { ->expression('rewards', 'rewards + :inc', [':inc' => 1]) ->expression('updated', ':timestamp', [':timestamp' => $timestamp]) ->execute(); - - // Also update experiment totals timestamp + + // Also update experiment totals timestamp. $this->database->merge('rl_experiment_totals') ->key(['experiment_uuid' => $experiment_uuid]) ->fields([ @@ -131,4 +130,4 @@ public function getTotalTurns($experiment_uuid) { return $result ? (int) $result : 0; } -} \ No newline at end of file +} diff --git a/src/Storage/ExperimentDataStorageInterface.php b/src/Storage/ExperimentDataStorageInterface.php index 4011b35..d6b116b 100644 --- a/src/Storage/ExperimentDataStorageInterface.php +++ b/src/Storage/ExperimentDataStorageInterface.php @@ -72,4 +72,4 @@ public function getAllArmsData($experiment_uuid); */ public function getTotalTurns($experiment_uuid); -} \ No newline at end of file +} From ff8391d482183854f67ace7c7f979a8184fb6ba5 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 11 Aug 2025 07:28:41 +0200 Subject: [PATCH 2/5] docs --- docs/rl_project_desc.html | 9 +++++- src/Storage/ExperimentDataStorage.php | 41 ++++++++++++++++++++------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/rl_project_desc.html b/docs/rl_project_desc.html index 2207b16..726407d 100644 --- a/docs/rl_project_desc.html +++ b/docs/rl_project_desc.html @@ -2,7 +2,8 @@
The Reinforcement Learning (RL) module implements A/B testing in the most efficient and effective way possible, minizing lost conversions using machine learning.
-

Thompson Sampling is a learning-while-doing method. Each time a visitor lands on your site the algorithm “rolls the dice” based on what it has learned so far. Variants that have performed well roll larger numbers, so they are shown more often, while weak copies still get a small chance to prove themselves. This simple trick means the system can discover winners very quickly without stopping normal traffic.

+ +

Thompson Sampling is a learning-while-doing method. Each time a visitor lands on your site the algorithm "rolls the dice" based on what it has learned so far. Variants that have performed well roll larger numbers, so they are shown more often, while weak copies still get a small chance to prove themselves. This simple trick means the system can discover winners very quickly without stopping normal traffic.

Traditional A/B tests run for a fixed horizon—say two weeks—during which half your visitors keep seeing the weaker version. Thompson Sampling avoids this waste. As soon as the algorithm has even a little evidence it quietly shifts most traffic to the better variant, saving conversions and shortening the wait for useful insights.

@@ -47,6 +48,12 @@

Thompson Sampling

  • Bayesian approach - Incorporates uncertainty
  • +
    +

    Prefer a turnkey demo site?

    +

    Spin up DXPR CMS—Drupal pre-configured with DXPR Builder, DXPR Theme, RL (Reinforcement Learning) module, and security best practices.

    +

    Get DXPR CMS »

    +
    +

    API

    // Get the experiment manager
    diff --git a/src/Storage/ExperimentDataStorage.php b/src/Storage/ExperimentDataStorage.php
    index 444497b..8bd5016 100644
    --- a/src/Storage/ExperimentDataStorage.php
    +++ b/src/Storage/ExperimentDataStorage.php
    @@ -109,24 +109,43 @@ public function getArmData($experiment_uuid, $arm_id) {
       /**
        * {@inheritdoc}
        */
    -  public function getAllArmsData($experiment_uuid) {
    -    return $this->database->select('rl_arm_data', 'ad')
    +  public function getAllArmsData($experiment_uuid, $time_window_days = NULL) {
    +    $query = $this->database->select('rl_arm_data', 'ad')
           ->fields('ad', ['arm_id', 'turns', 'rewards', 'created', 'updated'])
    -      ->condition('experiment_uuid', $experiment_uuid)
    -      ->execute()
    -      ->fetchAllAssoc('arm_id');
    +      ->condition('experiment_uuid', $experiment_uuid);
    +
    +    // Apply time window filter if specified.
    +    if ($time_window_days !== NULL && $time_window_days > 0) {
    +      $cutoff_timestamp = \Drupal::time()->getRequestTime() - ($time_window_days * 86400);
    +      $query->condition('updated', $cutoff_timestamp, '>=');
    +    }
    +
    +    return $query->execute()->fetchAllAssoc('arm_id');
       }
     
       /**
        * {@inheritdoc}
        */
    -  public function getTotalTurns($experiment_uuid) {
    -    $result = $this->database->select('rl_experiment_totals', 'et')
    -      ->fields('et', ['total_turns'])
    -      ->condition('experiment_uuid', $experiment_uuid)
    -      ->execute()
    -      ->fetchField();
    +  public function getTotalTurns($experiment_uuid, $time_window_days = NULL) {
    +    // If no time window, use the cached total.
    +    if ($time_window_days === NULL || $time_window_days <= 0) {
    +      $result = $this->database->select('rl_experiment_totals', 'et')
    +        ->fields('et', ['total_turns'])
    +        ->condition('experiment_uuid', $experiment_uuid)
    +        ->execute()
    +        ->fetchField();
    +
    +      return $result ? (int) $result : 0;
    +    }
    +
    +    // With time window, calculate total from arms within the window.
    +    $cutoff_timestamp = \Drupal::time()->getRequestTime() - ($time_window_days * 86400);
    +    $query = $this->database->select('rl_arm_data', 'ad');
    +    $query->addExpression('SUM(turns)', 'total_turns');
    +    $query->condition('experiment_uuid', $experiment_uuid)
    +      ->condition('updated', $cutoff_timestamp, '>=');
     
    +    $result = $query->execute()->fetchField();
         return $result ? (int) $result : 0;
       }
     
    
    From dde5c72f2957f59d071598023ba269f44fac74f1 Mon Sep 17 00:00:00 2001
    From: Jurriaan Roelofs 
    Date: Mon, 11 Aug 2025 07:54:51 +0200
    Subject: [PATCH 3/5] feat(admin): add CRUD operations for RL experiments with
     permissions
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Implements comprehensive admin interface for managing RL experiments with
    granular permission control and proper separation of concerns.
    
    **New Features:**
    - Added CRUD forms for creating, editing, and deleting experiments
    - Granular permissions: 'administer rl experiments' and 'view rl reports'
    - Admin operations (Edit/Delete) shown conditionally based on permissions
    - Add experiment button for users with admin permissions
    
    **Changes:**
    - Updated permissions.yml with more specific, secure permission names
    - Added 3 new routes: add, edit, delete experiment forms
    - Enhanced ReportsController with permission-based operations column
    - Created ExperimentForm for create/update operations with validation
    - Created ExperimentDeleteForm with confirmation and cascade delete
    
    **Security:**
    - Restricted 'administer rl experiments' permission with 'restrict access: true'
    - Separated read-only 'view rl reports' from admin permissions
    - Form validation prevents UUID conflicts and ensures data integrity
    - Transaction-based delete with proper error handling and rollback
    
    **UX Improvements:**
    - Operations column shows View | Edit | Delete for admins, View-only for others
    - Primary action button styling for "Add experiment"
    - Clear confirmation dialog for destructive delete operations
    - Proper cancel links and redirects maintain workflow continuity
    
    🤖 Generated with [Claude Code](https://claude.ai/code)
    
    Co-Authored-By: Claude 
    ---
     rl.permissions.yml                   |  13 +--
     rl.routing.yml                       |  28 ++++-
     src/Controller/ReportsController.php |  38 ++++++-
     src/Form/ExperimentDeleteForm.php    | 131 ++++++++++++++++++++++++
     src/Form/ExperimentForm.php          | 147 +++++++++++++++++++++++++++
     5 files changed, 346 insertions(+), 11 deletions(-)
     create mode 100644 src/Form/ExperimentDeleteForm.php
     create mode 100644 src/Form/ExperimentForm.php
    
    diff --git a/rl.permissions.yml b/rl.permissions.yml
    index 7a3ac45..0b89254 100644
    --- a/rl.permissions.yml
    +++ b/rl.permissions.yml
    @@ -1,7 +1,8 @@
    -administer reinforcement learning:
    -  title: 'Administer Reinforcement Learning'
    -  description: 'Manage RL experiments and view analytics'
    +administer rl experiments:
    +  title: 'Administer RL experiments'
    +  description: 'Create, update, and delete reinforcement learning experiments.'
    +  restrict access: true
     
    -view reinforcement learning data:
    -  title: 'View Reinforcement Learning data'
    -  description: 'Access experiment scores and analytics'
    +view rl reports:
    +  title: 'View RL reports'  
    +  description: 'View reinforcement learning experiment reports and statistics.'
    diff --git a/rl.routing.yml b/rl.routing.yml
    index 2a6a2f0..47e55c6 100644
    --- a/rl.routing.yml
    +++ b/rl.routing.yml
    @@ -40,13 +40,39 @@ rl.reports.experiments:
         _controller: '\Drupal\rl\Controller\ReportsController::experimentsOverview'
         _title: 'RL Experiments'
       requirements:
    -    _permission: 'administer rl experiments'
    +    _permission: 'view rl reports'
     
     rl.reports.experiment_detail:
       path: '/admin/reports/rl/experiment/{experiment_uuid}'
       defaults:
         _controller: '\Drupal\rl\Controller\ReportsController::experimentDetail'
         _title: 'RL Experiment Detail'
    +  requirements:
    +    _permission: 'view rl reports'
    +    experiment_uuid: '.+'
    +
    +rl.experiment.add:
    +  path: '/admin/reports/rl/add'
    +  defaults:
    +    _form: '\Drupal\rl\Form\ExperimentForm'
    +    _title: 'Add RL Experiment'
    +  requirements:
    +    _permission: 'administer rl experiments'
    +
    +rl.experiment.edit:
    +  path: '/admin/reports/rl/experiment/{experiment_uuid}/edit'
    +  defaults:
    +    _form: '\Drupal\rl\Form\ExperimentForm'
    +    _title: 'Edit RL Experiment'
    +  requirements:
    +    _permission: 'administer rl experiments'
    +    experiment_uuid: '.+'
    +
    +rl.experiment.delete:
    +  path: '/admin/reports/rl/experiment/{experiment_uuid}/delete'
    +  defaults:
    +    _form: '\Drupal\rl\Form\ExperimentDeleteForm'
    +    _title: 'Delete RL Experiment'
       requirements:
         _permission: 'administer rl experiments'
         experiment_uuid: '.+'
    diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php
    index 552a2ad..bdd3185 100644
    --- a/src/Controller/ReportsController.php
    +++ b/src/Controller/ReportsController.php
    @@ -111,10 +111,28 @@ public function experimentsOverview() {
             ->execute()
             ->fetchField();
     
    +      $operations = [];
    +
           $detail_url = Url::fromRoute('rl.reports.experiment_detail', [
             'experiment_uuid' => $experiment->uuid,
           ]);
    -      $detail_link = Link::fromTextAndUrl($this->t('View details'), $detail_url);
    +      $operations[] = Link::fromTextAndUrl($this->t('View'), $detail_url);
    +
    +      if ($this->currentUser()->hasPermission('administer rl experiments')) {
    +        $edit_url = Url::fromRoute('rl.experiment.edit', [
    +          'experiment_uuid' => $experiment->uuid,
    +        ]);
    +        $operations[] = Link::fromTextAndUrl($this->t('Edit'), $edit_url);
    +
    +        $delete_url = Url::fromRoute('rl.experiment.delete', [
    +          'experiment_uuid' => $experiment->uuid,
    +        ]);
    +        $operations[] = Link::fromTextAndUrl($this->t('Delete'), $delete_url);
    +      }
    +
    +      $operations_markup = implode(' | ', array_map(function ($link) {
    +        return $link->toString();
    +      }, $operations));
     
           // Format last activity timestamp - use totals_updated if available, otherwise registered_at.
           $last_activity_timestamp = $experiment->totals_updated ?: $experiment->registered_at;
    @@ -132,11 +150,24 @@ public function experimentsOverview() {
             $experiment->total_turns ?: 0,
             $arms_count,
             $last_activity,
    -        $detail_link,
    +        ['data' => ['#markup' => $operations_markup]],
           ];
         }
     
    -    $build = [
    +    $build = [];
    +
    +    if ($this->currentUser()->hasPermission('administer rl experiments')) {
    +      $add_url = Url::fromRoute('rl.experiment.add');
    +      $build['add_link'] = [
    +        '#type' => 'link',
    +        '#title' => $this->t('Add experiment'),
    +        '#url' => $add_url,
    +        '#attributes' => ['class' => ['button', 'button--primary']],
    +        '#suffix' => '

    ', + ]; + } + + $build['table'] = [ '#theme' => 'table', '#header' => $header, '#rows' => $rows, @@ -144,7 +175,6 @@ public function experimentsOverview() { '#caption' => $this->t('All Reinforcement Learning experiments and their statistics.'), ]; - // Add some explanatory text. $build['#prefix'] = '

    ' . $this->t('This page shows all active reinforcement learning experiments. Each experiment represents a multi-armed bandit test where different "arms" (options) are being evaluated based on user interactions (turns and rewards).') . '

    '; return $build; diff --git a/src/Form/ExperimentDeleteForm.php b/src/Form/ExperimentDeleteForm.php new file mode 100644 index 0000000..4f751db --- /dev/null +++ b/src/Form/ExperimentDeleteForm.php @@ -0,0 +1,131 @@ +database = $database; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('database') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'rl_experiment_delete_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $experiment_uuid = NULL) { + $this->experimentUuid = $experiment_uuid; + + $experiment = $this->database->select('rl_experiment_registry', 'er') + ->fields('er') + ->condition('uuid', $experiment_uuid) + ->execute() + ->fetchObject(); + + if (!$experiment) { + $this->messenger()->addError($this->t('Experiment not found.')); + return $this->redirect('rl.reports.experiments'); + } + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->t('Are you sure you want to delete experiment %uuid?', [ + '%uuid' => $this->experimentUuid, + ]); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('This will permanently delete the experiment and all its data (turns, rewards, totals). This action cannot be undone.'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('rl.reports.experiments'); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $transaction = $this->database->startTransaction(); + + try { + $this->database->delete('rl_arm_data') + ->condition('experiment_uuid', $this->experimentUuid) + ->execute(); + + $this->database->delete('rl_experiment_totals') + ->condition('experiment_uuid', $this->experimentUuid) + ->execute(); + + $this->database->delete('rl_experiment_registry') + ->condition('uuid', $this->experimentUuid) + ->execute(); + + $this->messenger()->addStatus($this->t('Experiment %uuid has been deleted.', [ + '%uuid' => $this->experimentUuid, + ])); + } + catch (\Exception $e) { + $transaction->rollBack(); + $this->messenger()->addError($this->t('An error occurred while deleting the experiment.')); + $this->getLogger('rl')->error('Error deleting experiment @uuid: @message', [ + '@uuid' => $this->experimentUuid, + '@message' => $e->getMessage(), + ]); + } + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/src/Form/ExperimentForm.php b/src/Form/ExperimentForm.php new file mode 100644 index 0000000..064522c --- /dev/null +++ b/src/Form/ExperimentForm.php @@ -0,0 +1,147 @@ +database = $database; + $this->experimentRegistry = $experiment_registry; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('database'), + $container->get('rl.experiment_registry') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'rl_experiment_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $experiment_uuid = NULL) { + $experiment = NULL; + + if ($experiment_uuid) { + $experiment = $this->database->select('rl_experiment_registry', 'er') + ->fields('er') + ->condition('uuid', $experiment_uuid) + ->execute() + ->fetchObject(); + + if (!$experiment) { + $this->messenger()->addError($this->t('Experiment not found.')); + return $this->redirect('rl.reports.experiments'); + } + } + + $form['uuid'] = [ + '#type' => 'textfield', + '#title' => $this->t('Experiment UUID'), + '#required' => TRUE, + '#default_value' => $experiment ? $experiment->uuid : '', + '#disabled' => (bool) $experiment, + '#description' => $experiment ? $this->t('UUID cannot be changed after creation.') : $this->t('Unique identifier for this experiment.'), + ]; + + $form['module'] = [ + '#type' => 'textfield', + '#title' => $this->t('Module'), + '#required' => TRUE, + '#default_value' => $experiment ? $experiment->module : '', + '#description' => $this->t('The module that owns this experiment.'), + ]; + + $form['actions'] = [ + '#type' => 'actions', + ]; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $experiment ? $this->t('Update experiment') : $this->t('Create experiment'), + '#button_type' => 'primary', + ]; + + $form['actions']['cancel'] = [ + '#type' => 'link', + '#title' => $this->t('Cancel'), + '#url' => $this->urlGenerator()->generateFromRoute('rl.reports.experiments'), + '#attributes' => ['class' => ['button']], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $uuid = $form_state->getValue('uuid'); + $route_uuid = $this->getRouteMatch()->getParameter('experiment_uuid'); + + if (!$route_uuid && $this->experimentRegistry->isRegistered($uuid)) { + $form_state->setErrorByName('uuid', $this->t('An experiment with this UUID already exists.')); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $uuid = $form_state->getValue('uuid'); + $module = $form_state->getValue('module'); + $route_uuid = $this->getRouteMatch()->getParameter('experiment_uuid'); + + if ($route_uuid) { + $this->database->update('rl_experiment_registry') + ->fields(['module' => $module]) + ->condition('uuid', $uuid) + ->execute(); + + $this->messenger()->addStatus($this->t('Experiment updated successfully.')); + } + else { + $this->experimentRegistry->register($uuid, $module); + $this->messenger()->addStatus($this->t('Experiment created successfully.')); + } + + $form_state->setRedirect('rl.reports.experiments'); + } + +} From 28bc9c0f4c28f16fc6181ceed456ff920a33fa83 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 11 Aug 2025 07:56:21 +0200 Subject: [PATCH 4/5] refactor(ui): improve RL experiments table layout and naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Operations column to first position for better UX - Rename "Module" column to "Ownership" for clearer semantics - Operations are now the first thing users see in each row 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/Controller/ReportsController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php index bdd3185..8808e57 100644 --- a/src/Controller/ReportsController.php +++ b/src/Controller/ReportsController.php @@ -83,12 +83,12 @@ public static function create(ContainerInterface $container) { */ public function experimentsOverview() { $header = [ + $this->t('Operations'), $this->t('Experiment ID'), - $this->t('Module'), + $this->t('Ownership'), $this->t('Total Turns'), $this->t('Total Arms'), $this->t('Last Activity'), - $this->t('Operations'), ]; $rows = []; @@ -145,12 +145,12 @@ public function experimentsOverview() { $experiment_name = $experiment_display ? \Drupal::service('renderer')->renderPlain($experiment_display) : $experiment->uuid; $rows[] = [ + ['data' => ['#markup' => $operations_markup]], $experiment_name, $experiment->module, $experiment->total_turns ?: 0, $arms_count, $last_activity, - ['data' => ['#markup' => $operations_markup]], ]; } From ec8c8f26e98628515d0d3f3d0830d409a226a35a Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 11 Aug 2025 09:19:18 +0200 Subject: [PATCH 5/5] feat: add seconds-based time window support for Thompson Sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getAllArmsDataWithWindow() method to storage interface and implementation - Add getThompsonScoresWithWindow() method to manager interface and implementation - Implement time-based filtering using seconds for technical precision - Add optimized composite indexes for time window query performance - Add covering index on rl_arm_data for optimal SELECT performance - Maintain backward compatibility with existing getAllArmsData() method - Support filtering arms by activity timestamp (updated column) BREAKING CHANGE: New database indexes require fresh installation or manual index updates Addresses sliding time window functionality for non-stationary content optimization. Enables content marketers to focus recommendations on recently active content. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- rl.install | 8 ++--- src/Service/ExperimentManager.php | 13 +++++++- src/Service/ExperimentManagerInterface.php | 13 ++++++++ src/Storage/ExperimentDataStorage.php | 30 ++++++++++++------- .../ExperimentDataStorageInterface.php | 13 ++++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/rl.install b/rl.install index 5313199..6d165fb 100644 --- a/rl.install +++ b/rl.install @@ -56,7 +56,7 @@ function rl_install() { 'experiment_unique' => ['experiment_uuid'], ], 'indexes' => [ - 'experiment_uuid' => ['experiment_uuid'], + 'experiment_updated' => ['experiment_uuid', 'updated'], ], ]; @@ -123,7 +123,7 @@ function rl_install() { 'experiment_arm' => ['experiment_uuid', 'arm_id'], ], 'indexes' => [ - 'experiment_uuid' => ['experiment_uuid'], + 'covering_time_window' => ['experiment_uuid', 'updated', 'arm_id', 'turns', 'rewards', 'created'], ], ]; @@ -249,7 +249,7 @@ function rl_schema() { 'experiment_unique' => ['experiment_uuid'], ], 'indexes' => [ - 'experiment_uuid' => ['experiment_uuid'], + 'experiment_updated' => ['experiment_uuid', 'updated'], ], ]; @@ -307,7 +307,7 @@ function rl_schema() { 'experiment_arm' => ['experiment_uuid', 'arm_id'], ], 'indexes' => [ - 'experiment_uuid' => ['experiment_uuid'], + 'covering_time_window' => ['experiment_uuid', 'updated', 'arm_id', 'turns', 'rewards', 'created'], ], ]; diff --git a/src/Service/ExperimentManager.php b/src/Service/ExperimentManager.php index 3361ebd..54b3163 100644 --- a/src/Service/ExperimentManager.php +++ b/src/Service/ExperimentManager.php @@ -81,7 +81,18 @@ public function getTotalTurns($experiment_uuid) { * {@inheritdoc} */ public function getThompsonScores($experiment_uuid) { - $arms_data = $this->getAllArmsData($experiment_uuid); + return $this->getThompsonScoresWithWindow($experiment_uuid, NULL); + } + + /** + * {@inheritdoc} + */ + public function getThompsonScoresWithWindow($experiment_uuid, $time_window_seconds = NULL) { + $arms_data = $this->storage->getAllArmsDataWithWindow($experiment_uuid, $time_window_seconds); + + if (empty($arms_data)) { + return []; + } return $this->tsCalculator->calculateThompsonScores($arms_data); } diff --git a/src/Service/ExperimentManagerInterface.php b/src/Service/ExperimentManagerInterface.php index 1a67fcc..ba5e745 100644 --- a/src/Service/ExperimentManagerInterface.php +++ b/src/Service/ExperimentManagerInterface.php @@ -83,4 +83,17 @@ public function getTotalTurns($experiment_uuid); */ public function getThompsonScores($experiment_uuid); + /** + * Gets Thompson Sampling scores for all arms with seconds-based time window. + * + * @param string $experiment_uuid + * The experiment UUID. + * @param int|null $time_window_seconds + * Optional time window in seconds. Only considers arms active within this timeframe. + * + * @return array + * Array of Thompson Sampling scores keyed by arm_id. + */ + public function getThompsonScoresWithWindow($experiment_uuid, $time_window_seconds = NULL); + } diff --git a/src/Storage/ExperimentDataStorage.php b/src/Storage/ExperimentDataStorage.php index 8bd5016..668593c 100644 --- a/src/Storage/ExperimentDataStorage.php +++ b/src/Storage/ExperimentDataStorage.php @@ -110,17 +110,11 @@ public function getArmData($experiment_uuid, $arm_id) { * {@inheritdoc} */ public function getAllArmsData($experiment_uuid, $time_window_days = NULL) { - $query = $this->database->select('rl_arm_data', 'ad') - ->fields('ad', ['arm_id', 'turns', 'rewards', 'created', 'updated']) - ->condition('experiment_uuid', $experiment_uuid); - - // Apply time window filter if specified. - if ($time_window_days !== NULL && $time_window_days > 0) { - $cutoff_timestamp = \Drupal::time()->getRequestTime() - ($time_window_days * 86400); - $query->condition('updated', $cutoff_timestamp, '>='); + $time_window_seconds = NULL; + if ($time_window_days && $time_window_days > 0) { + $time_window_seconds = $time_window_days * 86400; } - - return $query->execute()->fetchAllAssoc('arm_id'); + return $this->getAllArmsDataWithWindow($experiment_uuid, $time_window_seconds); } /** @@ -149,4 +143,20 @@ public function getTotalTurns($experiment_uuid, $time_window_days = NULL) { return $result ? (int) $result : 0; } + /** + * {@inheritdoc} + */ + public function getAllArmsDataWithWindow($experiment_uuid, $time_window_seconds = NULL) { + $query = $this->database->select('rl_arm_data', 'ad') + ->fields('ad', ['arm_id', 'turns', 'rewards', 'created', 'updated']) + ->condition('experiment_uuid', $experiment_uuid); + + if ($time_window_seconds && $time_window_seconds > 0) { + $cutoff_timestamp = \Drupal::time()->getRequestTime() - $time_window_seconds; + $query->condition('updated', $cutoff_timestamp, '>='); + } + + return $query->execute()->fetchAllAssoc('arm_id'); + } + } diff --git a/src/Storage/ExperimentDataStorageInterface.php b/src/Storage/ExperimentDataStorageInterface.php index d6b116b..e2a4bc0 100644 --- a/src/Storage/ExperimentDataStorageInterface.php +++ b/src/Storage/ExperimentDataStorageInterface.php @@ -72,4 +72,17 @@ public function getAllArmsData($experiment_uuid); */ public function getTotalTurns($experiment_uuid); + /** + * Gets data for all arms in an experiment with seconds-based time window. + * + * @param string $experiment_uuid + * The experiment UUID. + * @param int|null $time_window_seconds + * Optional time window in seconds. Only returns arms active within this timeframe. + * + * @return array + * Array of arm data objects keyed by arm_id. + */ + public function getAllArmsDataWithWindow($experiment_uuid, $time_window_seconds = NULL); + }