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/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 @@Spin up DXPR CMS—Drupal pre-configured with DXPR Builder, DXPR Theme, RL (Reinforcement Learning) module, and security best practices.
+ +// Get the experiment manager
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..6d165fb 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;
@@ -57,7 +56,7 @@ function rl_install() {
'experiment_unique' => ['experiment_uuid'],
],
'indexes' => [
- 'experiment_uuid' => ['experiment_uuid'],
+ 'experiment_updated' => ['experiment_uuid', 'updated'],
],
];
@@ -124,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'],
],
];
@@ -250,7 +249,7 @@ function rl_schema() {
'experiment_unique' => ['experiment_uuid'],
],
'indexes' => [
- 'experiment_uuid' => ['experiment_uuid'],
+ 'experiment_updated' => ['experiment_uuid', 'updated'],
],
];
@@ -308,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'],
],
];
@@ -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..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'
\ No newline at end of file
+view rl reports:
+ title: 'View RL reports'
+ description: 'View reinforcement learning experiment reports and statistics.'
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..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'
- experiment_uuid: '.+'
\ No newline at end of file
+
+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/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..8808e57 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')
+ );
}
/**
@@ -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 = [];
@@ -104,39 +104,70 @@ 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()
->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
+ // 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;
$rows[] = [
+ ['data' => ['#markup' => $operations_markup]],
$experiment_name,
$experiment->module,
$experiment->total_turns ?: 0,
$arms_count,
$last_activity,
- $detail_link,
];
}
- $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;
@@ -160,7 +190,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 +198,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 +221,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 +253,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/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');
+ }
+
+}
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..54b3163 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.
*
@@ -83,9 +81,20 @@ 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);
}
-}
\ No newline at end of file
+}
diff --git a/src/Service/ExperimentManagerInterface.php b/src/Service/ExperimentManagerInterface.php
index 9692e34..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);
-}
\ No newline at end of file
+ /**
+ * 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/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..668593c 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([
@@ -110,25 +109,54 @@ public function getArmData($experiment_uuid, $arm_id) {
/**
* {@inheritdoc}
*/
- public function getAllArmsData($experiment_uuid) {
- return $this->database->select('rl_arm_data', 'ad')
- ->fields('ad', ['arm_id', 'turns', 'rewards', 'created', 'updated'])
- ->condition('experiment_uuid', $experiment_uuid)
- ->execute()
- ->fetchAllAssoc('arm_id');
+ public function getAllArmsData($experiment_uuid, $time_window_days = NULL) {
+ $time_window_seconds = NULL;
+ if ($time_window_days && $time_window_days > 0) {
+ $time_window_seconds = $time_window_days * 86400;
+ }
+ return $this->getAllArmsDataWithWindow($experiment_uuid, $time_window_seconds);
}
/**
* {@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;
}
-}
\ No newline at end of file
+ /**
+ * {@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 4011b35..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);
-}
\ No newline at end of file
+ /**
+ * 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);
+
+}