phpstan.neon
+parameters:
+ paths:
+ - web/modules/contrib/rl
+ # Set the analysis level (0-9)
+ level: 5
+ # Treat PHPDoc types as less certain to avoid false positives with Drupal API methods
+ treatPhpDocTypesAsCertain: false
+EOF
+
+mkdir -p web/modules/contrib/
-# Install the statistic modules if D11 (removed from core).
-if [[ $DRUPAL_RECOMMENDED_PROJECT == 11.* ]]; then
- composer require drupal/statistics
+if [ ! -L "web/modules/contrib/rl" ]; then
+ ln -s /src web/modules/contrib/rl
fi
-# Install drupal-check
-composer require $DRUPAL_CHECK_TOOL --dev
+# Install PHPStan extensions for Drupal 11 and Drush for command analysis
+composer require --dev phpstan/phpstan mglaman/phpstan-drupal phpstan/phpstan-deprecation-rules drush/drush --with-all-dependencies --no-interaction
-# Run drupal-check
-./vendor/bin/drupal-check --drupal-root . -ad web/modules/contrib/analyze_ai_brand_voice
\ No newline at end of file
+# Run phpstan
+./vendor/bin/phpstan analyse --memory-limit=-1 -c phpstan.neon
\ No newline at end of file
diff --git a/scripts/run-drupal-lint-auto-fix.sh b/scripts/run-drupal-lint-auto-fix.sh
index 949ebe6..feae358 100755
--- a/scripts/run-drupal-lint-auto-fix.sh
+++ b/scripts/run-drupal-lint-auto-fix.sh
@@ -4,10 +4,10 @@ source scripts/prepare-drupal-lint.sh
phpcbf --standard=Drupal \
--extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \
- --ignore=node_modules,rl/vendor,.github,vendor \
+ --ignore=node_modules,rl/vendor,.github,vendor,README.md \
.
phpcbf --standard=DrupalPractice \
--extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \
- --ignore=node_modules,rl/vendor,.github,vendor \
+ --ignore=node_modules,rl/vendor,.github,vendor,README.md \
.
diff --git a/scripts/run-drupal-lint.sh b/scripts/run-drupal-lint.sh
index ff3ebb4..d1a2362 100755
--- a/scripts/run-drupal-lint.sh
+++ b/scripts/run-drupal-lint.sh
@@ -7,7 +7,7 @@ echo "---- Checking with PHPCompatibility PHP 8.3 and up ----"
phpcs --standard=PHPCompatibility \
--runtime-set testVersion 8.3- \
--extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \
- --ignore=node_modules,rl/vendor,.github,vendor \
+ --ignore=node_modules,rl/vendor,.github,vendor,README.md \
-v \
.
status=$?
@@ -18,7 +18,7 @@ fi
echo "---- Checking with Drupal standard... ----"
phpcs --standard=Drupal \
--extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \
- --ignore=node_modules,rl/vendor,.github,vendor \
+ --ignore=node_modules,rl/vendor,.github,vendor,README.md \
-v \
.
status=$?
@@ -29,7 +29,7 @@ fi
echo "---- Checking with DrupalPractice standard... ----"
phpcs --standard=DrupalPractice \
--extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \
- --ignore=node_modules,rl/vendor,.github,vendor \
+ --ignore=node_modules,rl/vendor,.github,vendor,README.md \
-v \
.
diff --git a/src/Controller/ExperimentController.php b/src/Controller/ExperimentController.php
index 3e03f65..5be1fdd 100644
--- a/src/Controller/ExperimentController.php
+++ b/src/Controller/ExperimentController.php
@@ -32,7 +32,8 @@ public function __construct(ExperimentManagerInterface $experiment_manager) {
/**
* {@inheritdoc}
*/
- public static function create(ContainerInterface $container) {
+ public static function create(ContainerInterface $container): static {
+ // @phpstan-ignore new.static
return new static(
$container->get('rl.experiment_manager')
);
diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php
index 7574e83..654b115 100644
--- a/src/Controller/ReportsController.php
+++ b/src/Controller/ReportsController.php
@@ -2,27 +2,24 @@
namespace Drupal\rl\Controller;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Drupal\Component\Utility\Html;
use Drupal\Core\Controller\ControllerBase;
-use Drupal\Core\Database\Connection;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Link;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Url;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\rl\Decorator\ExperimentDecoratorManager;
+use Drupal\rl\Service\ArmDataValidator;
use Drupal\rl\Storage\ExperimentDataStorageInterface;
+use Drupal\rl\Storage\SnapshotStorageInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
/**
* Controller for RL experiment reports.
*/
class ReportsController extends ControllerBase {
- /**
- * The database connection.
- *
- * @var \Drupal\Core\Database\Connection
- */
- protected $database;
/**
* The experiment data storage.
@@ -52,11 +49,30 @@ class ReportsController extends ControllerBase {
*/
protected $renderer;
+ /**
+ * The arm data validator.
+ *
+ * @var \Drupal\rl\Service\ArmDataValidator
+ */
+ protected ArmDataValidator $armDataValidator;
+
+ /**
+ * The snapshot storage.
+ *
+ * @var \Drupal\rl\Storage\SnapshotStorageInterface
+ */
+ protected SnapshotStorageInterface $snapshotStorage;
+
+ /**
+ * The request stack.
+ *
+ * @var \Symfony\Component\HttpFoundation\RequestStack
+ */
+ protected RequestStack $requestStack;
+
/**
* Constructs a ReportsController object.
*
- * @param \Drupal\Core\Database\Connection $database
- * The database connection.
* @param \Drupal\rl\Storage\ExperimentDataStorageInterface $experiment_storage
* The experiment data storage.
* @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
@@ -65,26 +81,41 @@ class ReportsController extends ControllerBase {
* The experiment decorator manager.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer service.
+ * @param \Drupal\rl\Service\ArmDataValidator $arm_data_validator
+ * The arm data validator.
+ * @param \Drupal\rl\Storage\SnapshotStorageInterface $snapshot_storage
+ * The snapshot storage.
+ * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+ * The request stack.
*/
- public function __construct(Connection $database, ExperimentDataStorageInterface $experiment_storage, DateFormatterInterface $date_formatter, ExperimentDecoratorManager $decorator_manager, RendererInterface $renderer) {
- $this->database = $database;
+ public function __construct(ExperimentDataStorageInterface $experiment_storage, DateFormatterInterface $date_formatter, ExperimentDecoratorManager $decorator_manager, RendererInterface $renderer, ArmDataValidator $arm_data_validator, SnapshotStorageInterface $snapshot_storage, RequestStack $request_stack) {
$this->experimentStorage = $experiment_storage;
$this->dateFormatter = $date_formatter;
$this->decoratorManager = $decorator_manager;
$this->renderer = $renderer;
+ $this->armDataValidator = $arm_data_validator;
+ $this->snapshotStorage = $snapshot_storage;
+ $this->requestStack = $request_stack;
}
/**
* {@inheritdoc}
+ *
+ * PHPStan note: The 'new.static' warning is suppressed because Drupal's
+ * dependency injection pattern requires static factories in non-final
+ * controller classes. This is standard Drupal architecture.
*/
- public static function create(ContainerInterface $container) {
+ public static function create(ContainerInterface $container): static {
+ // @phpstan-ignore new.static
return new static(
- $container->get('database'),
- $container->get('rl.experiment_data_storage'),
- $container->get('date.formatter'),
- $container->get('rl.experiment_decorator_manager'),
- $container->get('renderer')
- );
+ $container->get('rl.experiment_data_storage'),
+ $container->get('date.formatter'),
+ $container->get('rl.experiment_decorator_manager'),
+ $container->get('renderer'),
+ $container->get('rl.arm_data_validator'),
+ $container->get('rl.snapshot_storage'),
+ $container->get('request_stack')
+ );
}
/**
@@ -96,32 +127,22 @@ public static function create(ContainerInterface $container) {
public function experimentsOverview() {
$header = [
$this->t('Operations'),
- $this->t('Experiment ID'),
- $this->t('Ownership'),
- $this->t('Total Turns'),
- $this->t('Total Arms'),
+ $this->t('Experiment'),
+ $this->t('Source'),
+ $this->t('Impressions'),
+ $this->t('Conversions'),
+ $this->t('Variants'),
$this->t('Last Activity'),
];
$rows = [];
- // Get all registered experiments with their totals (if any)
- $query = $this->database->select('rl_experiment_registry', 'er')
- ->fields('er', ['experiment_id', 'module', 'experiment_name', 'registered_at']);
- $query->leftJoin('rl_experiment_totals', 'et', 'er.experiment_id = et.experiment_id');
- $query->addField('et', 'total_turns', 'total_turns');
- $query->addField('et', 'created', 'totals_created');
- $query->addField('et', 'updated', 'totals_updated');
- $query->orderBy('er.registered_at', 'DESC');
- $experiments = $query->execute()->fetchAll();
+ // Get all experiments with their statistics from storage.
+ $experiments = $this->experimentStorage->getExperimentsWithStats();
foreach ($experiments as $experiment) {
- // Count arms for this experiment.
- $arms_count = $this->database->select('rl_arm_data', 'ad')
- ->condition('experiment_id', $experiment->experiment_id)
- ->countQuery()
- ->execute()
- ->fetchField();
+ $arms_count = $experiment->arm_count;
+ $total_rewards = $experiment->total_rewards;
$operations = [];
@@ -156,6 +177,7 @@ public function experimentsOverview() {
$experiment_name,
$experiment->module,
$experiment->total_turns ?: 0,
+ $total_rewards,
$arms_count,
$last_activity,
];
@@ -189,10 +211,9 @@ public function experimentsOverview() {
'#header' => $header,
'#rows' => $rows,
'#empty' => $this->t('No experiments found.'),
- '#caption' => $this->t('All Reinforcement Learning experiments and their statistics.'),
];
- $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).') . '
';
+ $build['#prefix'] = '' . $this->t('Tip: Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '
';
return $build;
}
@@ -207,74 +228,612 @@ public function experimentsOverview() {
* A render array.
*/
public function experimentDetail($experiment_id) {
- // Get experiment totals.
- $experiment_totals = $this->database->select('rl_experiment_totals', 'et')
- ->fields('et')
- ->condition('experiment_id', $experiment_id)
- ->execute()
- ->fetchObject();
+ // Get experiment totals from storage.
+ $experiment_totals = $this->experimentStorage->getExperimentTotals($experiment_id);
if (!$experiment_totals) {
throw new NotFoundHttpException();
}
- // Get all arms for this experiment.
- $arms_query = $this->database->select('rl_arm_data', 'ad')
- ->fields('ad')
- ->condition('experiment_id', $experiment_id)
- ->orderBy('updated', 'DESC');
- $arms = $arms_query->execute()->fetchAll();
+ // Get all arms for this experiment from storage.
+ $arms = $this->experimentStorage->getArmsByExperiment($experiment_id);
+
+ $build = [];
+
+ // Get date range from request or use defaults.
+ $request = $this->requestStack->getCurrentRequest();
+ $preset = $request ? $request->query->get('preset', '') : '';
+ $start_date = $request ? $request->query->get('start') : NULL;
+ $end_date = $request ? $request->query->get('end') : NULL;
+ $time_axis = $request ? $request->query->get('axis', 'trials') : 'trials';
+
+ // Validate time axis value.
+ $valid_axes = ['trials', 'daily', 'weekly', 'monthly', 'quarterly'];
+ if (!in_array($time_axis, $valid_axes)) {
+ $time_axis = 'trials';
+ }
+
+ // Calculate date range from preset if provided.
+ $date_range = $this->calculateDateRange($preset, $start_date, $end_date);
+
+ // Get available date range for this experiment.
+ $available_range = $this->snapshotStorage->getSnapshotDateRange($experiment_id);
+
+ // Build date filter form for charts.
+ $date_filter = NULL;
+ if (!empty($available_range)) {
+ $date_filter = $this->buildDateFilterForm($experiment_id, $date_range, $available_range, $preset, $time_axis);
+ }
+
+ // Add charts if we have snapshot data.
+ $snapshots = $this->snapshotStorage->getSnapshotHistory(
+ $experiment_id,
+ $date_range['start'] ?? NULL,
+ $date_range['end'] ?? NULL
+ );
+ if (!empty($snapshots)) {
+ $build['charts'] = $this->buildCharts($experiment_id, $snapshots, $arms, $time_axis, $date_filter);
+ }
+ else {
+ // Show appropriate message based on whether event logging is enabled.
+ if ($this->snapshotStorage->isEnabled()) {
+ $message = $this->t('No data yet. Charts appear after the experiment receives traffic.');
+ }
+ elseif ($this->currentUser()->hasPermission('administer site configuration')) {
+ $settings_url = Url::fromRoute('rl.settings')->toString();
+ $message = $this->t('Event logging is disabled. Enable it in Reinforcement Learning settings to see historical charts.', [
+ '@url' => $settings_url,
+ ]);
+ }
+ else {
+ $message = $this->t('Historical charts are not available for this experiment.');
+ }
+ $build['no_charts'] = [
+ '#markup' => '' . $message . '
',
+ ];
+ }
+ // Build sortable header - use field specifier for tablesorter.
$header = [
- $this->t('Arm ID'),
- $this->t('Turns'),
- $this->t('Rewards'),
- $this->t('Success Rate'),
- $this->t('TS Score'),
+ ['data' => $this->t('Variant'), 'field' => 'arm_id'],
+ ['data' => $this->t('Impressions'), 'field' => 'turns'],
+ ['data' => $this->t('Conversions'), 'field' => 'rewards'],
+ ['data' => $this->t('Rate'), 'field' => 'success_rate', 'sort' => 'desc'],
];
- $rows = [];
-
+ // Build row data with sortable values.
+ $arm_data = [];
foreach ($arms as $arm) {
- $success_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0;
+ // Validate and sanitize arm data.
+ $arm = $this->armDataValidator->validateAndSanitize($arm, $experiment_id, $arm->arm_id);
- // Calculate Thompson Sampling score.
- $alpha_param = $arm->rewards + 1;
- $beta_param = ($arm->turns - $arm->rewards) + 1;
- // Beta mean as approximation.
- $ts_score = $alpha_param / ($alpha_param + $beta_param);
+ $success_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0;
- // Get decorated arm name or fallback to arm ID.
+ // Get decorated arm name or fallback to escaped arm ID.
$arm_display = $this->decoratorManager->decorateArm($experiment_id, $arm->arm_id);
- $arm_name = $arm_display ? $this->renderer->renderPlain($arm_display) : $arm->arm_id;
+ $arm_name = $arm_display ? $this->renderer->renderInIsolation($arm_display) : Html::escape($arm->arm_id);
+
+ $arm_data[] = [
+ 'arm_id' => $arm->arm_id,
+ 'arm_name' => $arm_name,
+ 'turns' => (int) $arm->turns,
+ 'rewards' => (int) $arm->rewards,
+ 'success_rate' => $success_rate,
+ ];
+ }
+ // Sort by the selected column.
+ $order = $request ? $request->query->get('order', 'Rate') : 'Rate';
+ $sort = $request ? $request->query->get('sort', 'desc') : 'desc';
+
+ $sort_field = 'success_rate';
+ if (stripos($order, 'Variant') !== FALSE) {
+ $sort_field = 'arm_id';
+ }
+ elseif (stripos($order, 'Impression') !== FALSE) {
+ $sort_field = 'turns';
+ }
+ elseif (stripos($order, 'Conversion') !== FALSE) {
+ $sort_field = 'rewards';
+ }
+
+ // @phpstan-ignore argument.unresolvableType, argument.unresolvableType
+ usort($arm_data, static function (array $a, array $b) use ($sort_field, $sort): int {
+ $cmp = $a[$sort_field] <=> $b[$sort_field];
+ return $sort === 'desc' ? -$cmp : $cmp;
+ });
+
+ // Build rows for display.
+ $rows = [];
+ foreach ($arm_data as $data) {
$rows[] = [
- $arm_name,
- $arm->turns,
- $arm->rewards,
- number_format($success_rate, 2) . '%',
- number_format($ts_score, 4),
+ ['data' => ['#markup' => $data['arm_name']]],
+ $data['turns'],
+ $data['rewards'],
+ number_format($data['success_rate'], 2) . '%',
];
}
- $table = [
+ $build['table'] = [
'#theme' => 'table',
'#header' => $header,
'#rows' => $rows,
- '#empty' => $this->t('No arms found for this experiment.'),
- '#caption' => $this->t('All arms in this experiment with their performance data.'),
+ '#empty' => $this->t('No variants found.'),
+ '#attributes' => ['class' => ['rl-sortable-table']],
+ ];
+
+ return $build;
+ }
+
+ /**
+ * Build charts render array.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ * @param array $snapshots
+ * Array of snapshot objects.
+ * @param array $arms
+ * Array of arm objects with current state.
+ * @param string $time_axis
+ * Time axis type: 'trials', 'daily', 'weekly', 'monthly',
+ * or 'quarterly'.
+ * @param array|null $date_filter
+ * Optional date filter render array.
+ *
+ * @return array
+ * Render array with charts.
+ */
+ protected function buildCharts(string $experiment_id, array $snapshots, array $arms, string $time_axis = 'trials', ?array $date_filter = NULL): array {
+ // Organize snapshots by arm and calculate chart data.
+ $arms_data = [];
+ $all_x_values = [];
+ $x_axis_label = $this->t('Total Impressions');
+ $x_labels = [];
+
+ foreach ($snapshots as $snapshot) {
+ $arm_id = $snapshot->arm_id;
+ $created = (int) $snapshot->created;
+
+ // Calculate x-axis value based on time axis type.
+ if ($time_axis === 'trials') {
+ $x_value = (int) $snapshot->total_experiment_turns;
+ }
+ else {
+ $x_value = $this->getTimeBucket($created, $time_axis);
+ }
+
+ if (!isset($arms_data[$arm_id])) {
+ $arms_data[$arm_id] = [];
+ }
+
+ $turns = (int) $snapshot->turns;
+ $rewards = (int) $snapshot->rewards;
+
+ // Calculate posterior mean.
+ $alpha = $rewards + 1;
+ $beta = max(1, $turns - $rewards + 1);
+ $mean = $alpha / ($alpha + $beta);
+
+ // For time-based axes, keep the latest snapshot per bucket.
+ if (!isset($arms_data[$arm_id][$x_value]) || $created > $arms_data[$arm_id][$x_value]['created']) {
+ $arms_data[$arm_id][$x_value] = [
+ 'mean' => $mean,
+ 'turns' => $turns,
+ 'rewards' => $rewards,
+ 'created' => $created,
+ ];
+ }
+
+ $all_x_values[$x_value] = $created;
+ }
+
+ ksort($all_x_values);
+ $x_values = array_keys($all_x_values);
+
+ // Generate x-axis labels for time-based axes.
+ if ($time_axis !== 'trials') {
+ $x_axis_label = $this->getTimeAxisLabel($time_axis);
+ foreach ($x_values as $x_value) {
+ $x_labels[$x_value] = $this->formatTimeBucket($x_value, $time_axis);
+ }
+ }
+
+ // Sort arms by total turns (activity).
+ $arm_totals = [];
+ foreach ($arms as $arm) {
+ $arm_totals[$arm->arm_id] = (int) $arm->turns;
+ }
+ arsort($arm_totals);
+
+ // Use up to 100 arms for 3D Plotly visualizations.
+ $top_arms_3d = array_slice(array_keys($arm_totals), 0, 100);
+
+ // Build arm label map using decorators for human-readable names.
+ $arm_labels = [];
+ foreach (array_keys($arm_totals) as $arm_id) {
+ $arm_display = $this->decoratorManager->decorateArm($experiment_id, $arm_id);
+ if ($arm_display) {
+ // Render and strip HTML tags for chart labels.
+ $label = strip_tags($this->renderer->renderInIsolation($arm_display));
+ // Decode HTML entities to show proper quotes and special chars.
+ $label = html_entity_decode($label, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ // Full labels for 3D charts (up to 60 chars for better readability).
+ $arm_labels[$arm_id] = mb_strlen($label) > 60 ? mb_substr($label, 0, 57) . '...' : $label;
+ }
+ else {
+ // Fallback to truncated arm ID.
+ $arm_labels[$arm_id] = strlen($arm_id) > 40 ? substr($arm_id, 0, 37) . '...' : $arm_id;
+ }
+ }
+
+ // Generate colors for arms.
+ $colors = [
+ 'rgba(255, 99, 132, 1)',
+ 'rgba(54, 162, 235, 1)',
+ 'rgba(255, 206, 86, 1)',
+ 'rgba(75, 192, 192, 1)',
+ 'rgba(153, 102, 255, 1)',
+ 'rgba(255, 159, 64, 1)',
+ 'rgba(199, 199, 199, 1)',
+ 'rgba(83, 102, 255, 1)',
+ 'rgba(255, 99, 255, 1)',
+ 'rgba(99, 255, 132, 1)',
+ ];
+
+ // Prepare chart data for both 2D line and 3D surface (single loop).
+ $line_chart_data = ['arms' => []];
+ $surface_3d_data = ['xValues' => $x_values, 'armLabels' => [], 'zMatrix' => []];
+ $i = 0;
+ foreach ($top_arms_3d as $arm_id) {
+ if (!isset($arms_data[$arm_id])) {
+ continue;
+ }
+ $label = $arm_labels[$arm_id];
+ $color = $colors[$i % count($colors)];
+ $data_points = [];
+ $z_row = [];
+
+ foreach ($x_values as $x) {
+ if (isset($arms_data[$arm_id][$x])) {
+ $rate = round($arms_data[$arm_id][$x]['mean'] * 100, 2);
+ $data_points[] = ['x' => $x, 'y' => $rate];
+ $z_row[] = $rate;
+ }
+ else {
+ // Find closest previous value for 3D surface interpolation.
+ $closest = NULL;
+ foreach ($arms_data[$arm_id] as $px => $point) {
+ if ($px <= $x) {
+ $closest = $point;
+ }
+ }
+ $z_row[] = $closest ? round($closest['mean'] * 100, 2) : 0;
+ }
+ }
+
+ $line_chart_data['arms'][] = ['label' => $label, 'data' => $data_points, 'color' => $color];
+ $surface_3d_data['armLabels'][] = $label;
+ $surface_3d_data['zMatrix'][] = $z_row;
+ $i++;
+ }
+
+ // Plotly data (up to 100 arms for 3D visualizations).
+ $chart_line_threshold = $this->config('rl.settings')->get('chart_line_threshold') ?? 10;
+ $total_arms_all = count($arm_totals);
+ $plotly_data = [
+ 'lineChartData' => $line_chart_data,
+ 'surface3d' => $surface_3d_data,
+ 'totalArmsDisplayed' => count($top_arms_3d),
+ 'totalArmsAll' => $total_arms_all,
+ 'chartLineThreshold' => $chart_line_threshold,
+ 'timeAxis' => $time_axis,
+ 'xAxisLabel' => (string) $x_axis_label,
+ 'xLabels' => !empty($x_labels) ? $x_labels : NULL,
];
$build = [
- '#title' => $this->t('RL Experiment: @id', ['@id' => $experiment_id]),
- 'table' => $table,
+ '#type' => 'container',
+ '#attributes' => ['class' => ['rl-charts-container', 'rl-plotly-container']],
+ '#attached' => [
+ 'library' => ['rl/plotly'],
+ 'drupalSettings' => [
+ 'rlPlotly' => $plotly_data,
+ ],
+ ],
];
- // 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)') . '
';
+ $build['charts'] = [
+ '#theme' => 'rl_charts',
+ '#title' => $this->t('Performance Over Time'),
+ '#tip_hover' => $this->t('Hover for details. Higher = better.'),
+ '#tip_taller' => $this->t('Hover for details. Taller/brighter = better conversion rate.'),
+ '#interaction_hint' => $this->t('Drag to rotate @bullet Scroll to zoom', ['@bullet' => '•']),
+ '#date_filter' => $date_filter,
+ ];
return $build;
}
+ /**
+ * Calculate date range from preset or explicit dates.
+ *
+ * @param string $preset
+ * Preset name (e.g., 'last_4_weeks', 'this_month').
+ * @param string|null $start_date
+ * Explicit start date (Y-m-d format).
+ * @param string|null $end_date
+ * Explicit end date (Y-m-d format).
+ *
+ * @return array
+ * Array with 'start' and 'end' timestamps, or empty for all data.
+ */
+ protected function calculateDateRange(string $preset, ?string $start_date, ?string $end_date): array {
+ $today_end = strtotime('today 23:59:59');
+
+ // Handle relative presets (last_X_days, last_X_weeks).
+ if (preg_match('/^last_(\d+)_(day|days|week|weeks)$/', $preset, $matches)) {
+ $amount = $matches[1];
+ $unit = str_contains($matches[2], 'day') ? 'days' : 'weeks';
+ return [
+ 'start' => strtotime("-{$amount} {$unit} midnight"),
+ 'end' => $today_end,
+ ];
+ }
+
+ // Handle named presets.
+ $named_presets = [
+ 'this_month' => 'first day of this month midnight',
+ 'this_year' => 'first day of January this year midnight',
+ ];
+ if (isset($named_presets[$preset])) {
+ return ['start' => strtotime($named_presets[$preset]), 'end' => $today_end];
+ }
+
+ // Handle this_quarter specially (requires calculation).
+ if ($preset === 'this_quarter') {
+ $month = (int) date('n');
+ $quarter_start_month = (int) (floor(($month - 1) / 3) * 3 + 1);
+ return [
+ 'start' => strtotime(date('Y') . '-' . str_pad((string) $quarter_start_month, 2, '0', STR_PAD_LEFT) . '-01 midnight'),
+ 'end' => $today_end,
+ ];
+ }
+
+ // Handle explicit dates.
+ if ($start_date || $end_date) {
+ $range = [];
+ if ($start_date && preg_match('/^\d{4}-\d{2}-\d{2}$/', $start_date)) {
+ $range['start'] = strtotime($start_date . ' 00:00:00');
+ }
+ if ($end_date && preg_match('/^\d{4}-\d{2}-\d{2}$/', $end_date)) {
+ $range['end'] = strtotime($end_date . ' 23:59:59');
+ }
+ return $range;
+ }
+
+ // No filter - return empty array for all data.
+ return [];
+ }
+
+ /**
+ * Get the time bucket for a timestamp based on granularity.
+ *
+ * @param int $timestamp
+ * The Unix timestamp.
+ * @param string $granularity
+ * The granularity: 'hourly', 'daily', 'weekly', 'monthly', 'quarterly'.
+ *
+ * @return int
+ * A bucket identifier (timestamp of bucket start).
+ */
+ protected function getTimeBucket(int $timestamp, string $granularity): int {
+ switch ($granularity) {
+ case 'hourly':
+ return (int) strtotime(date('Y-m-d H:00:00', $timestamp));
+
+ case 'daily':
+ return (int) strtotime(date('Y-m-d', $timestamp));
+
+ case 'weekly':
+ // Start of week (Monday).
+ return (int) strtotime('monday this week', $timestamp);
+
+ case 'monthly':
+ return (int) strtotime(date('Y-m-01', $timestamp));
+
+ case 'quarterly':
+ $month = (int) date('n', $timestamp);
+ $quarter_month = (int) (floor(($month - 1) / 3) * 3 + 1);
+ return (int) strtotime(date('Y', $timestamp) . '-' . str_pad((string) $quarter_month, 2, '0', STR_PAD_LEFT) . '-01');
+
+ default:
+ return $timestamp;
+ }
+ }
+
+ /**
+ * Format a time bucket for display.
+ *
+ * @param int $bucket
+ * The bucket timestamp.
+ * @param string $granularity
+ * The granularity.
+ *
+ * @return string
+ * Formatted label.
+ */
+ protected function formatTimeBucket(int $bucket, string $granularity): string {
+ switch ($granularity) {
+ case 'hourly':
+ return date('M j, H:00', $bucket);
+
+ case 'daily':
+ return date('M j', $bucket);
+
+ case 'weekly':
+ return 'W' . date('W', $bucket) . ' ' . date('M j', $bucket);
+
+ case 'monthly':
+ return date('M Y', $bucket);
+
+ case 'quarterly':
+ $month = (int) date('n', $bucket);
+ $quarter = (int) ceil($month / 3);
+ return 'Q' . $quarter . ' ' . date('Y', $bucket);
+
+ default:
+ return (string) $bucket;
+ }
+ }
+
+ /**
+ * Get the x-axis label for a time axis type.
+ *
+ * @param string $time_axis
+ * The time axis type.
+ *
+ * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+ * The axis label.
+ */
+ protected function getTimeAxisLabel(string $time_axis) {
+ switch ($time_axis) {
+ case 'hourly':
+ return $this->t('Hour');
+
+ case 'daily':
+ return $this->t('Date');
+
+ case 'weekly':
+ return $this->t('Week');
+
+ case 'monthly':
+ return $this->t('Month');
+
+ case 'quarterly':
+ return $this->t('Quarter');
+
+ default:
+ return $this->t('Total Impressions');
+ }
+ }
+
+ /**
+ * Build the date filter form.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ * @param array $current_range
+ * Currently selected date range.
+ * @param array $available_range
+ * Available date range from snapshots.
+ * @param string $current_preset
+ * Currently selected preset.
+ * @param string $current_axis
+ * Currently selected time axis.
+ *
+ * @return array
+ * Render array for date filter.
+ */
+ protected function buildDateFilterForm(string $experiment_id, array $current_range, array $available_range, string $current_preset, string $current_axis = 'trials'): array {
+ $base_url = Url::fromRoute('rl.reports.experiment_detail', [
+ 'experiment_id' => $experiment_id,
+ ]);
+
+ $presets = [
+ '' => $this->t('All time'),
+ 'last_1_day' => $this->t('Last 1 day'),
+ 'last_5_days' => $this->t('Last 5 days'),
+ 'last_1_week' => $this->t('Last 1 week'),
+ 'last_2_weeks' => $this->t('Last 2 weeks'),
+ 'last_4_weeks' => $this->t('Last 4 weeks'),
+ 'last_8_weeks' => $this->t('Last 8 weeks'),
+ 'last_12_weeks' => $this->t('Last 12 weeks'),
+ 'last_24_weeks' => $this->t('Last 24 weeks'),
+ 'this_month' => $this->t('This month'),
+ 'this_quarter' => $this->t('This quarter'),
+ 'this_year' => $this->t('This year'),
+ ];
+
+ $axes = [
+ 'trials' => $this->t('Impressions'),
+ 'daily' => $this->t('Daily'),
+ 'weekly' => $this->t('Weekly'),
+ 'monthly' => $this->t('Monthly'),
+ 'quarterly' => $this->t('Quarterly'),
+ ];
+
+ // Helper to build dropdown options with URLs.
+ $build_options = function (array $items, string $param, string $other_param, string $other_value, string $other_default) use ($base_url): array {
+ $options = $urls = [];
+ foreach ($items as $key => $label) {
+ $url = clone $base_url;
+ $query = [];
+ if ($key && $key !== $other_default) {
+ $query[$param] = $key;
+ }
+ if ($other_value && $other_value !== $other_default) {
+ $query[$other_param] = $other_value;
+ }
+ if (!empty($query)) {
+ $url->setOption('query', $query);
+ }
+ $options[$key] = $label;
+ $urls[$key] = $url->toString();
+ }
+ return ['options' => $options, 'urls' => $urls];
+ };
+
+ $preset_data = $build_options($presets, 'preset', 'axis', $current_axis, 'trials');
+ $axis_data = $build_options($axes, 'axis', 'preset', $current_preset, '');
+
+ // Format available date range for display.
+ $range_text = $this->t('Data available from @start to @end', [
+ '@start' => $this->dateFormatter->format($available_range['min'], 'short'),
+ '@end' => $this->dateFormatter->format($available_range['max'], 'short'),
+ ]);
+
+ return [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['rl-date-filter']],
+ 'presets' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['rl-presets']],
+ 'label' => [
+ '#markup' => '' . $this->t('Time range:') . ' ',
+ ],
+ 'select' => [
+ '#type' => 'select',
+ '#options' => $preset_data['options'],
+ '#value' => $current_preset,
+ '#attributes' => [
+ 'class' => ['rl-preset-select', 'rl-filter-select'],
+ 'data-urls' => json_encode($preset_data['urls']),
+ ],
+ ],
+ ],
+ 'axes' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['rl-axes']],
+ 'label' => [
+ '#markup' => '' . $this->t('X-axis:') . ' ',
+ ],
+ 'select' => [
+ '#type' => 'select',
+ '#options' => $axis_data['options'],
+ '#value' => $current_axis,
+ '#attributes' => [
+ 'class' => ['rl-axis-select', 'rl-filter-select'],
+ 'data-urls' => json_encode($axis_data['urls']),
+ ],
+ ],
+ ],
+ 'range_info' => [
+ '#markup' => '' . $range_text . '
',
+ ],
+ '#attached' => [
+ 'library' => ['rl/date-filter'],
+ ],
+ ];
+ }
+
}
diff --git a/src/Form/DisableEventLogConfirmForm.php b/src/Form/DisableEventLogConfirmForm.php
new file mode 100644
index 0000000..549fd33
--- /dev/null
+++ b/src/Form/DisableEventLogConfirmForm.php
@@ -0,0 +1,107 @@
+snapshotStorage = $snapshot_storage;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container): DisableEventLogConfirmForm {
+ return new self(
+ $container->get('rl.snapshot_storage')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return 'rl_disable_event_log_confirm_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getQuestion() {
+ return $this->t('Are you sure you want to disable event logging?');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ $count = $this->snapshotStorage->getCount();
+ if ($count > 0) {
+ return $this->t('This will permanently delete all @count event log entries. The historical visualization charts will no longer be available for any experiments. This action cannot be undone.', [
+ '@count' => number_format($count),
+ ]);
+ }
+ return $this->t('Event logging will be disabled. No log entries exist to delete.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfirmText() {
+ return $this->t('Disable and delete all logs');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCancelUrl(): Url {
+ return new Url('rl.settings');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state): void {
+ // Delete all snapshots.
+ $deleted = $this->snapshotStorage->deleteAll();
+
+ // Disable event logging.
+ $this->configFactory()->getEditable('rl.settings')
+ ->set('enable_event_log', FALSE)
+ ->save();
+
+ if ($deleted > 0) {
+ $this->messenger()->addStatus($this->t('Event logging has been disabled and @count log entries have been deleted.', [
+ '@count' => number_format($deleted),
+ ]));
+ }
+ else {
+ $this->messenger()->addStatus($this->t('Event logging has been disabled.'));
+ }
+
+ $form_state->setRedirectUrl($this->getCancelUrl());
+ }
+
+}
diff --git a/src/Form/ExperimentDeleteForm.php b/src/Form/ExperimentDeleteForm.php
index 700059c..0e56c48 100644
--- a/src/Form/ExperimentDeleteForm.php
+++ b/src/Form/ExperimentDeleteForm.php
@@ -37,7 +37,8 @@ public function __construct(Connection $database) {
/**
* {@inheritdoc}
*/
- public static function create(ContainerInterface $container) {
+ public static function create(ContainerInterface $container): static {
+ // @phpstan-ignore new.static
return new static(
$container->get('database')
);
@@ -53,7 +54,7 @@ public function getFormId() {
/**
* {@inheritdoc}
*/
- public function buildForm(array $form, FormStateInterface $form_state, $experiment_id = NULL) {
+ public function buildForm(array $form, FormStateInterface $form_state, $experiment_id = NULL): array {
$this->experimentId = $experiment_id;
$experiment = $this->database->select('rl_experiment_registry', 'er')
@@ -64,7 +65,8 @@ public function buildForm(array $form, FormStateInterface $form_state, $experime
if (!$experiment) {
$this->messenger()->addError($this->t('Experiment not found.'));
- return $this->redirect('rl.reports.experiments');
+ $form_state->setRedirect('rl.reports.experiments');
+ return [];
}
return parent::buildForm($form, $form_state);
@@ -83,7 +85,7 @@ public function getQuestion() {
* {@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.');
+ return $this->t('This will permanently delete the experiment and all its data (turns, rewards, totals, snapshots). This action cannot be undone.');
}
/**
@@ -108,6 +110,10 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
->condition('experiment_id', $this->experimentId)
->execute();
+ $this->database->delete('rl_arm_snapshots')
+ ->condition('experiment_id', $this->experimentId)
+ ->execute();
+
$this->database->delete('rl_experiment_registry')
->condition('experiment_id', $this->experimentId)
->execute();
diff --git a/src/Form/ExperimentForm.php b/src/Form/ExperimentForm.php
index 2d60b3e..48b9f4a 100644
--- a/src/Form/ExperimentForm.php
+++ b/src/Form/ExperimentForm.php
@@ -5,6 +5,7 @@
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Database\Connection;
+use Drupal\Core\Url;
use Drupal\rl\Registry\ExperimentRegistryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -38,7 +39,8 @@ public function __construct(Connection $database, ExperimentRegistryInterface $e
/**
* {@inheritdoc}
*/
- public static function create(ContainerInterface $container) {
+ public static function create(ContainerInterface $container): static {
+ // @phpstan-ignore new.static
return new static(
$container->get('database'),
$container->get('rl.experiment_registry')
@@ -55,7 +57,7 @@ public function getFormId() {
/**
* {@inheritdoc}
*/
- public function buildForm(array $form, FormStateInterface $form_state, $experiment_id = NULL) {
+ public function buildForm(array $form, FormStateInterface $form_state, $experiment_id = NULL): array {
$experiment = NULL;
if ($experiment_id) {
@@ -67,7 +69,8 @@ public function buildForm(array $form, FormStateInterface $form_state, $experime
if (!$experiment) {
$this->messenger()->addError($this->t('Experiment not found.'));
- return $this->redirect('rl.reports.experiments');
+ $form_state->setRedirect('rl.reports.experiments');
+ return [];
}
}
@@ -101,7 +104,7 @@ public function buildForm(array $form, FormStateInterface $form_state, $experime
$form['actions']['cancel'] = [
'#type' => 'link',
'#title' => $this->t('Cancel'),
- '#url' => $this->urlGenerator()->generateFromRoute('rl.reports.experiments'),
+ '#url' => Url::fromRoute('rl.reports.experiments'),
'#attributes' => ['class' => ['button']],
];
diff --git a/src/Form/RlSettingsForm.php b/src/Form/RlSettingsForm.php
index 8eef246..c134eb0 100644
--- a/src/Form/RlSettingsForm.php
+++ b/src/Form/RlSettingsForm.php
@@ -4,6 +4,7 @@
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
/**
* Configure RL module settings.
@@ -13,21 +14,21 @@ class RlSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
- public function getFormId() {
+ public function getFormId(): string {
return 'rl_settings_form';
}
/**
* {@inheritdoc}
*/
- protected function getEditableConfigNames() {
+ protected function getEditableConfigNames(): array {
return ['rl.settings'];
}
/**
* {@inheritdoc}
*/
- public function buildForm(array $form, FormStateInterface $form_state) {
+ public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('rl.settings');
$form['debug_mode'] = [
@@ -37,15 +38,71 @@ public function buildForm(array $form, FormStateInterface $form_state) {
'#default_value' => $config->get('debug_mode') ?? FALSE,
];
+ $form['event_log'] = [
+ '#type' => 'details',
+ '#title' => $this->t('Event Log (Historical Visualization)'),
+ '#open' => TRUE,
+ ];
+
+ $form['event_log']['enable_event_log'] = [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Enable event log'),
+ '#description' => $this->t('When enabled, snapshots of experiment state are recorded over time to allow visualization of how posterior beliefs evolved. This adds one database write per turn/reward.'),
+ '#default_value' => $config->get('enable_event_log') ?? FALSE,
+ ];
+
+ $form['event_log']['event_log_max_rows'] = [
+ '#type' => 'number',
+ '#title' => $this->t('Maximum event log rows'),
+ '#description' => $this->t('Maximum rows in rl_arm_snapshots. Cron deletes non-milestone rows when over limit. Preserved: early trials (40% of per-arm budget, permanent), recent trials (40%, rotating), and periodic milestone samples at adaptive intervals.'),
+ '#default_value' => $config->get('event_log_max_rows') ?? 100000,
+ '#min' => 1000,
+ '#max' => 10000000,
+ '#states' => [
+ 'visible' => [
+ ':input[name="enable_event_log"]' => ['checked' => TRUE],
+ ],
+ ],
+ ];
+
+ $form['charts'] = [
+ '#type' => 'details',
+ '#title' => $this->t('Chart Settings'),
+ '#open' => TRUE,
+ ];
+
+ $form['charts']['chart_line_threshold'] = [
+ '#type' => 'number',
+ '#title' => $this->t('Line chart threshold'),
+ '#description' => $this->t('Maximum number of variants to show in the 2D line chart. Experiments with more variants will use the 3D landscape visualization instead.'),
+ '#default_value' => $config->get('chart_line_threshold') ?? 10,
+ '#min' => 2,
+ '#max' => 50,
+ ];
+
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
- public function submitForm(array &$form, FormStateInterface $form_state) {
+ public function submitForm(array &$form, FormStateInterface $form_state): void {
+ $config = $this->config('rl.settings');
+ $was_enabled = $config->get('enable_event_log') ?? FALSE;
+ $is_enabled = (bool) $form_state->getValue('enable_event_log');
+
+ // If disabling event log, redirect to confirmation form.
+ if ($was_enabled && !$is_enabled) {
+ $form_state->setRedirectUrl(Url::fromRoute('rl.settings.disable_event_log'));
+ return;
+ }
+
+ // Save all settings normally.
$this->config('rl.settings')
->set('debug_mode', $form_state->getValue('debug_mode'))
+ ->set('enable_event_log', $is_enabled)
+ ->set('event_log_max_rows', (int) $form_state->getValue('event_log_max_rows'))
+ ->set('chart_line_threshold', (int) $form_state->getValue('chart_line_threshold'))
->save();
parent::submitForm($form, $form_state);
diff --git a/src/Service/ArmDataValidator.php b/src/Service/ArmDataValidator.php
new file mode 100644
index 0000000..895ab1f
--- /dev/null
+++ b/src/Service/ArmDataValidator.php
@@ -0,0 +1,125 @@
+ turns): Sanitizes and logs since these
+ * can occur from race conditions or malicious requests; throwing would
+ * enable DoS.
+ */
+class ArmDataValidator {
+
+ /**
+ * The logger.
+ *
+ * @var \Psr\Log\LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * Constructs a new ArmDataValidator.
+ *
+ * @param \Psr\Log\LoggerInterface $logger
+ * The logger channel.
+ */
+ public function __construct(LoggerInterface $logger) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Validates arm data and throws exception if invalid.
+ *
+ * @param object $arm
+ * The arm data object with turns and rewards properties.
+ * @param string $experiment_id
+ * The experiment ID for logging context.
+ * @param string $arm_id
+ * The arm ID for logging context.
+ *
+ * @return object
+ * The validated arm data object with normalized types.
+ *
+ * @throws \RuntimeException
+ * If arm data is invalid.
+ */
+ public function validateAndSanitize(object $arm, string $experiment_id, string $arm_id): object {
+ // Ensure turns is a non-negative integer.
+ if (!is_numeric($arm->turns) || $arm->turns < 0) {
+ $this->logger->critical('The %field field has invalid value %value for experiment %experiment_id, arm %arm_id.', [
+ '%field' => 'turns',
+ '%value' => var_export($arm->turns, TRUE),
+ '%experiment_id' => $experiment_id,
+ '%arm_id' => $arm_id,
+ ]);
+ throw new \RuntimeException(sprintf(
+ 'Invalid turns value %s for experiment %s, arm %s. Expected non-negative integer.',
+ var_export($arm->turns, TRUE),
+ $experiment_id,
+ $arm_id
+ ));
+ }
+ $arm->turns = (int) $arm->turns;
+
+ // Ensure rewards is a non-negative integer.
+ if (!is_numeric($arm->rewards) || $arm->rewards < 0) {
+ $this->logger->critical('The %field field has invalid value %value for experiment %experiment_id, arm %arm_id.', [
+ '%field' => 'rewards',
+ '%value' => var_export($arm->rewards, TRUE),
+ '%experiment_id' => $experiment_id,
+ '%arm_id' => $arm_id,
+ ]);
+ throw new \RuntimeException(sprintf(
+ 'Invalid rewards value %s for experiment %s, arm %s. Expected non-negative integer.',
+ var_export($arm->rewards, TRUE),
+ $experiment_id,
+ $arm_id
+ ));
+ }
+ $arm->rewards = (int) $arm->rewards;
+
+ // Critical validation: rewards cannot exceed turns.
+ // Note: We sanitize instead of throwing to prevent DoS attacks where
+ // malicious actors send reward requests to crash the site.
+ if ($arm->rewards > $arm->turns) {
+ $this->logger->critical('Data integrity violation: rewards (%rewards) exceeds turns (%turns) for experiment %experiment_id, arm %arm_id. This indicates database corruption, a bug in reward tracking, or malicious reward requests. Sanitizing to prevent site crash.', [
+ '%rewards' => $arm->rewards,
+ '%turns' => $arm->turns,
+ '%experiment_id' => $experiment_id,
+ '%arm_id' => $arm_id,
+ ]);
+ // Sanitize: cap rewards at turns to maintain mathematical validity.
+ $arm->rewards = $arm->turns;
+ }
+
+ return $arm;
+ }
+
+ /**
+ * Validates an array of arm data objects.
+ *
+ * @param array $arms_data
+ * Array of arm data objects keyed by arm ID.
+ * @param string $experiment_id
+ * The experiment ID for logging context.
+ *
+ * @return array
+ * The sanitized arms data array.
+ */
+ public function validateArmsData(array $arms_data, string $experiment_id): array {
+ foreach ($arms_data as $arm_id => $arm) {
+ $arms_data[$arm_id] = $this->validateAndSanitize($arm, $experiment_id, (string) $arm_id);
+ }
+ return $arms_data;
+ }
+
+}
diff --git a/src/Service/ExperimentManager.php b/src/Service/ExperimentManager.php
index 1941b05..ebd2c8b 100644
--- a/src/Service/ExperimentManager.php
+++ b/src/Service/ExperimentManager.php
@@ -46,6 +46,13 @@ class ExperimentManager implements ExperimentManagerInterface {
*/
protected $loggerFactory;
+ /**
+ * The arm data validator.
+ *
+ * @var \Drupal\rl\Service\ArmDataValidator
+ */
+ protected $armDataValidator;
+
/**
* Constructs a new ExperimentManager.
*
@@ -59,13 +66,16 @@ class ExperimentManager implements ExperimentManagerInterface {
* The database connection.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory.
+ * @param \Drupal\rl\Service\ArmDataValidator $arm_data_validator
+ * The arm data validator.
*/
- public function __construct(ExperimentDataStorageInterface $storage, ThompsonCalculator $ts_calculator, ConfigFactoryInterface $config_factory, Connection $database, LoggerChannelFactoryInterface $logger_factory) {
+ public function __construct(ExperimentDataStorageInterface $storage, ThompsonCalculator $ts_calculator, ConfigFactoryInterface $config_factory, Connection $database, LoggerChannelFactoryInterface $logger_factory, ArmDataValidator $arm_data_validator) {
$this->storage = $storage;
$this->tsCalculator = $ts_calculator;
$this->configFactory = $config_factory;
$this->database = $database;
$this->loggerFactory = $logger_factory;
+ $this->armDataValidator = $arm_data_validator;
}
/**
@@ -149,6 +159,9 @@ public function getThompsonScores($experiment_id, $time_window_seconds = NULL, a
}
}
+ // Validate and sanitize arm data before Thompson Sampling.
+ $arms_data = $this->armDataValidator->validateArmsData($arms_data, $experiment_id);
+
$scores = $this->tsCalculator->calculateThompsonScores($arms_data);
// Debug logging if enabled.
diff --git a/src/Service/ThompsonCalculator.php b/src/Service/ThompsonCalculator.php
index e16b923..6a98b34 100644
--- a/src/Service/ThompsonCalculator.php
+++ b/src/Service/ThompsonCalculator.php
@@ -49,6 +49,7 @@ public function calculateThompsonScores(array $arms_data): array {
foreach ($arms_data as $id => $arm) {
$alpha = $arm->rewards + 1;
$beta = ($arm->turns - $arm->rewards) + 1;
+
$base_score = $this->randBeta($alpha, $beta);
$tie_breaker = mt_rand(1, 999) / 1000000;
@@ -102,10 +103,22 @@ private function randBeta(int $alpha, int $beta): float {
* U^{1/k}. The scaling U^{1/k} converts the +1 shape back down.
*/
private function randGamma(float $k): float {
+ /* ----- Case k ≤ 0 (invalid/edge case) ------------------------- */
+ if ($k <= 0.0) {
+ // This should never happen with proper data validation.
+ // If it does, we want to fail loudly to expose the bug.
+ throw new \InvalidArgumentException(sprintf(
+ 'Invalid Gamma distribution shape parameter k=%f. ' .
+ 'This indicates a critical bug in arm data validation. ' .
+ 'Please check the ArmDataValidator service.',
+ $k
+ ));
+ }
+
/* ----- Case 0 < k < 1 ----------------------------------------- */
if ($k < 1.0) {
- // Draw Γ(k+1) and shrink it. The exponent 1/k acts like “take the
- // k-th root” of a uniform number, redistributing mass toward zero.
+ // Draw Γ(k+1) and shrink it. The exponent 1/k acts like "take the
+ // k-th root" of a uniform number, redistributing mass toward zero.
return $this->randGamma($k + 1.0) * pow($this->u(), 1.0 / $k);
}
diff --git a/src/Storage/ExperimentDataStorage.php b/src/Storage/ExperimentDataStorage.php
index c8cb708..63f7b60 100644
--- a/src/Storage/ExperimentDataStorage.php
+++ b/src/Storage/ExperimentDataStorage.php
@@ -23,6 +23,13 @@ class ExperimentDataStorage implements ExperimentDataStorageInterface {
*/
protected $time;
+ /**
+ * The snapshot storage service.
+ *
+ * @var \Drupal\rl\Storage\SnapshotStorageInterface|null
+ */
+ protected $snapshotStorage;
+
/**
* Constructs a new ExperimentDataStorage.
*
@@ -30,10 +37,13 @@ class ExperimentDataStorage implements ExperimentDataStorageInterface {
* The database connection.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
+ * @param \Drupal\rl\Storage\SnapshotStorageInterface|null $snapshot_storage
+ * The snapshot storage service.
*/
- public function __construct(Connection $database, TimeInterface $time) {
+ public function __construct(Connection $database, TimeInterface $time, ?SnapshotStorageInterface $snapshot_storage = NULL) {
$this->database = $database;
$this->time = $time;
+ $this->snapshotStorage = $snapshot_storage;
}
/**
@@ -65,6 +75,9 @@ public function recordTurn($experiment_id, $arm_id) {
->expression('total_turns', 'total_turns + :inc', [':inc' => 1])
->expression('updated', ':timestamp', [':timestamp' => $timestamp])
->execute();
+
+ // Record snapshot if enabled.
+ $this->maybeRecordSnapshots($experiment_id, [$arm_id]);
}
/**
@@ -99,6 +112,9 @@ public function recordTurns($experiment_id, array $arm_ids) {
->expression('total_turns', 'total_turns + :inc', [':inc' => $arm_count])
->expression('updated', ':timestamp', [':timestamp' => $timestamp])
->execute();
+
+ // Record snapshots if enabled.
+ $this->maybeRecordSnapshots($experiment_id, $arm_ids);
}
/**
@@ -127,6 +143,9 @@ public function recordReward($experiment_id, $arm_id) {
])
->expression('updated', ':timestamp', [':timestamp' => $timestamp])
->execute();
+
+ // Record snapshot for reward if enabled.
+ $this->maybeRecordSnapshots($experiment_id, [$arm_id]);
}
/**
@@ -170,4 +189,83 @@ public function getTotalTurns($experiment_id) {
return $result ? (int) $result : 0;
}
+ /**
+ * Record snapshots for arms if event logging is enabled.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ * @param array $arm_ids
+ * Array of arm IDs to snapshot.
+ */
+ protected function maybeRecordSnapshots(string $experiment_id, array $arm_ids): void {
+ if (!$this->snapshotStorage || !$this->snapshotStorage->isEnabled()) {
+ return;
+ }
+
+ $total_turns = $this->getTotalTurns($experiment_id);
+
+ foreach ($arm_ids as $arm_id) {
+ $arm_data = $this->getArmData($experiment_id, $arm_id);
+ if ($arm_data) {
+ $this->snapshotStorage->recordSnapshot(
+ $experiment_id,
+ $arm_id,
+ (int) $arm_data->turns,
+ (int) $arm_data->rewards,
+ $total_turns
+ );
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getExperimentsWithStats(): array {
+ $query = $this->database->select('rl_experiment_registry', 'er')
+ ->fields('er', ['experiment_id', 'module', 'experiment_name', 'registered_at']);
+ $query->leftJoin('rl_experiment_totals', 'et', 'er.experiment_id = et.experiment_id');
+ $query->addField('et', 'total_turns', 'total_turns');
+ $query->addField('et', 'created', 'totals_created');
+ $query->addField('et', 'updated', 'totals_updated');
+ $query->orderBy('er.registered_at', 'DESC');
+ $experiments = $query->execute()->fetchAll();
+
+ // Add arm counts and total rewards for each experiment.
+ foreach ($experiments as $experiment) {
+ $arm_stats = $this->database->select('rl_arm_data', 'ad')
+ ->condition('experiment_id', $experiment->experiment_id);
+ $arm_stats->addExpression('COUNT(*)', 'arm_count');
+ $arm_stats->addExpression('COALESCE(SUM(rewards), 0)', 'total_rewards');
+ $stats = $arm_stats->execute()->fetchObject();
+ $experiment->arm_count = $stats->arm_count ?? 0;
+ $experiment->total_rewards = $stats->total_rewards ?? 0;
+ }
+
+ return $experiments;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getExperimentTotals(string $experiment_id): ?object {
+ return $this->database->select('rl_experiment_totals', 'et')
+ ->fields('et')
+ ->condition('experiment_id', $experiment_id)
+ ->execute()
+ ->fetchObject() ?: NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getArmsByExperiment(string $experiment_id): array {
+ return $this->database->select('rl_arm_data', 'ad')
+ ->fields('ad')
+ ->condition('experiment_id', $experiment_id)
+ ->orderBy('updated', 'DESC')
+ ->execute()
+ ->fetchAll();
+ }
+
}
diff --git a/src/Storage/ExperimentDataStorageInterface.php b/src/Storage/ExperimentDataStorageInterface.php
index 7454125..bfd08d0 100644
--- a/src/Storage/ExperimentDataStorageInterface.php
+++ b/src/Storage/ExperimentDataStorageInterface.php
@@ -75,4 +75,34 @@ public function getAllArmsData($experiment_id, $time_window_seconds = NULL);
*/
public function getTotalTurns($experiment_id);
+ /**
+ * Gets all experiments with their statistics for the overview page.
+ *
+ * @return array
+ * Array of experiment objects with stats.
+ */
+ public function getExperimentsWithStats(): array;
+
+ /**
+ * Gets the experiment totals record.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ *
+ * @return object|null
+ * The experiment totals object or NULL if not found.
+ */
+ public function getExperimentTotals(string $experiment_id): ?object;
+
+ /**
+ * Gets all arms for an experiment with their data.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ *
+ * @return array
+ * Array of arm data objects.
+ */
+ public function getArmsByExperiment(string $experiment_id): array;
+
}
diff --git a/src/Storage/SnapshotStorage.php b/src/Storage/SnapshotStorage.php
new file mode 100644
index 0000000..5449a39
--- /dev/null
+++ b/src/Storage/SnapshotStorage.php
@@ -0,0 +1,372 @@
+database = $database;
+ $this->time = $time;
+ $this->configFactory = $config_factory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isEnabled(): bool {
+ return (bool) $this->configFactory->get('rl.settings')->get('enable_event_log');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function recordSnapshot(string $experiment_id, string $arm_id, int $turns, int $rewards, int $total_experiment_turns): void {
+ if (!$this->isEnabled()) {
+ return;
+ }
+
+ // Check if we should record a snapshot based on our budget strategy.
+ $arm_count = $this->getArmCount($experiment_id);
+ $snapshots_per_arm = $this->calculateSnapshotsPerArm($arm_count);
+
+ if (!$this->shouldRecordSnapshot($experiment_id, $arm_id, $total_experiment_turns, $snapshots_per_arm)) {
+ return;
+ }
+
+ $is_milestone = $this->isMilestone($total_experiment_turns, $snapshots_per_arm);
+
+ $this->database->insert('rl_arm_snapshots')
+ ->fields([
+ 'experiment_id' => $experiment_id,
+ 'arm_id' => $arm_id,
+ 'turns' => $turns,
+ 'rewards' => $rewards,
+ 'total_experiment_turns' => $total_experiment_turns,
+ 'created' => $this->time->getRequestTime(),
+ 'is_milestone' => $is_milestone ? 1 : 0,
+ ])
+ ->execute();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getSnapshotHistory(string $experiment_id, ?int $start_date = NULL, ?int $end_date = NULL): array {
+ $query = $this->database->select('rl_arm_snapshots', 's')
+ ->fields('s', ['arm_id', 'turns', 'rewards', 'total_experiment_turns', 'created'])
+ ->condition('experiment_id', $experiment_id);
+
+ if ($start_date !== NULL) {
+ $query->condition('created', $start_date, '>=');
+ }
+ if ($end_date !== NULL) {
+ $query->condition('created', $end_date, '<=');
+ }
+
+ return $query->orderBy('total_experiment_turns', 'ASC')
+ ->execute()
+ ->fetchAll();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getSnapshotDateRange(string $experiment_id): array {
+ $query = $this->database->select('rl_arm_snapshots', 's')
+ ->condition('experiment_id', $experiment_id);
+ $query->addExpression('MIN(created)', 'min_date');
+ $query->addExpression('MAX(created)', 'max_date');
+ $result = $query->execute()->fetchObject();
+
+ if ($result && $result->min_date && $result->max_date) {
+ return [
+ 'min' => (int) $result->min_date,
+ 'max' => (int) $result->max_date,
+ ];
+ }
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function cleanup(): int {
+ $deleted = 0;
+
+ // Get all experiments.
+ $experiments = $this->database->select('rl_arm_snapshots', 's')
+ ->fields('s', ['experiment_id'])
+ ->distinct()
+ ->execute()
+ ->fetchCol();
+
+ foreach ($experiments as $experiment_id) {
+ $arm_count = $this->getArmCount($experiment_id);
+ $snapshots_per_arm = $this->calculateSnapshotsPerArm($arm_count);
+ $recent_window = $this->calculateRecentWindow($snapshots_per_arm);
+
+ // Get all arms for this experiment.
+ $arms = $this->database->select('rl_arm_snapshots', 's')
+ ->fields('s', ['arm_id'])
+ ->condition('experiment_id', $experiment_id)
+ ->distinct()
+ ->execute()
+ ->fetchCol();
+
+ foreach ($arms as $arm_id) {
+ // Get non-milestone snapshots beyond recent window.
+ $subquery = $this->database->select('rl_arm_snapshots', 's')
+ ->fields('s', ['id'])
+ ->condition('experiment_id', $experiment_id)
+ ->condition('arm_id', $arm_id)
+ ->condition('is_milestone', 0)
+ ->orderBy('total_experiment_turns', 'DESC')
+ ->range($recent_window, 1000000);
+
+ $ids_to_delete = $subquery->execute()->fetchCol();
+
+ if (!empty($ids_to_delete)) {
+ $deleted += $this->database->delete('rl_arm_snapshots')
+ ->condition('id', $ids_to_delete, 'IN')
+ ->execute();
+ }
+ }
+ }
+
+ // Global cleanup if over max rows.
+ $max_rows = $this->configFactory->get('rl.settings')->get('event_log_max_rows') ?: 100000;
+ $total_rows = $this->database->select('rl_arm_snapshots', 's')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+
+ if ($total_rows > $max_rows) {
+ $to_delete = $total_rows - $max_rows;
+ // Delete oldest non-milestone rows.
+ $ids = $this->database->select('rl_arm_snapshots', 's')
+ ->fields('s', ['id'])
+ ->condition('is_milestone', 0)
+ ->orderBy('created', 'ASC')
+ ->range(0, $to_delete)
+ ->execute()
+ ->fetchCol();
+
+ if (!empty($ids)) {
+ $deleted += $this->database->delete('rl_arm_snapshots')
+ ->condition('id', $ids, 'IN')
+ ->execute();
+ }
+ }
+
+ return $deleted;
+ }
+
+ /**
+ * Calculate snapshots per arm based on arm count.
+ *
+ * @param int $arm_count
+ * Number of arms in experiment.
+ *
+ * @return int
+ * Snapshots allowed per arm.
+ */
+ protected function calculateSnapshotsPerArm(int $arm_count): int {
+ if ($arm_count <= 0) {
+ return self::MAX_SNAPSHOTS_PER_ARM;
+ }
+ return min(
+ self::MAX_SNAPSHOTS_PER_ARM,
+ max(20, (int) floor(self::MAX_ROWS_PER_EXPERIMENT / $arm_count))
+ );
+ }
+
+ /**
+ * Calculate the first N trials to capture at full resolution.
+ *
+ * @param int $snapshots_per_arm
+ * Total snapshot budget per arm.
+ *
+ * @return int
+ * Number of trials to capture at start.
+ */
+ protected function calculateFirstWindow(int $snapshots_per_arm): int {
+ // 40% of budget for first trials.
+ return (int) floor($snapshots_per_arm * 0.4);
+ }
+
+ /**
+ * Calculate the last N trials to capture at full resolution.
+ *
+ * @param int $snapshots_per_arm
+ * Total snapshot budget per arm.
+ *
+ * @return int
+ * Number of recent trials to keep.
+ */
+ protected function calculateRecentWindow(int $snapshots_per_arm): int {
+ // 40% of budget for recent trials.
+ return (int) floor($snapshots_per_arm * 0.4);
+ }
+
+ /**
+ * Calculate middle section snapshot interval.
+ *
+ * @param int $snapshots_per_arm
+ * Total snapshot budget per arm.
+ * @param int $total_turns
+ * Current total turns.
+ *
+ * @return int
+ * Interval between middle snapshots.
+ */
+ protected function calculateMiddleInterval(int $snapshots_per_arm, int $total_turns): int {
+ $first_window = $this->calculateFirstWindow($snapshots_per_arm);
+ $recent_window = $this->calculateRecentWindow($snapshots_per_arm);
+ $middle_budget = $snapshots_per_arm - $first_window - $recent_window;
+
+ if ($middle_budget <= 0 || $total_turns <= $first_window) {
+ return 1;
+ }
+
+ $middle_range = max(1, $total_turns - $first_window - $recent_window);
+ return max(1, (int) floor($middle_range / max(1, $middle_budget)));
+ }
+
+ /**
+ * Determine if we should record a snapshot at this point.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ * @param string $arm_id
+ * The arm ID.
+ * @param int $total_turns
+ * Current total experiment turns.
+ * @param int $snapshots_per_arm
+ * Snapshot budget per arm.
+ *
+ * @return bool
+ * TRUE if we should record.
+ */
+ protected function shouldRecordSnapshot(string $experiment_id, string $arm_id, int $total_turns, int $snapshots_per_arm): bool {
+ $first_window = $this->calculateFirstWindow($snapshots_per_arm);
+
+ // Always record in first window.
+ if ($total_turns <= $first_window) {
+ return TRUE;
+ }
+
+ // Always record recent (cleanup handles the window).
+ // For middle section, use interval.
+ $interval = $this->calculateMiddleInterval($snapshots_per_arm, $total_turns);
+ return ($total_turns % $interval) === 0;
+ }
+
+ /**
+ * Determine if this is a permanent milestone snapshot.
+ *
+ * @param int $total_turns
+ * Current total turns.
+ * @param int $snapshots_per_arm
+ * Snapshot budget per arm.
+ *
+ * @return bool
+ * TRUE if this is a milestone.
+ */
+ protected function isMilestone(int $total_turns, int $snapshots_per_arm): bool {
+ $first_window = $this->calculateFirstWindow($snapshots_per_arm);
+
+ // First window are all milestones.
+ if ($total_turns <= $first_window) {
+ return TRUE;
+ }
+
+ // Middle section milestones at interval points.
+ $interval = $this->calculateMiddleInterval($snapshots_per_arm, $total_turns);
+ return ($total_turns % $interval) === 0;
+ }
+
+ /**
+ * Get the number of arms for an experiment.
+ *
+ * @param string $experiment_id
+ * The experiment ID.
+ *
+ * @return int
+ * Number of arms.
+ */
+ protected function getArmCount(string $experiment_id): int {
+ $count = $this->database->select('rl_arm_data', 'a')
+ ->condition('experiment_id', $experiment_id)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+
+ return (int) $count ?: 1;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function deleteAll(): int {
+ return (int) $this->database->truncate('rl_arm_snapshots')->execute();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCount(): int {
+ return (int) $this->database->select('rl_arm_snapshots', 's')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ }
+
+}
diff --git a/src/Storage/SnapshotStorageInterface.php b/src/Storage/SnapshotStorageInterface.php
new file mode 100644
index 0000000..56c65c6
--- /dev/null
+++ b/src/Storage/SnapshotStorageInterface.php
@@ -0,0 +1,84 @@
+{{ title }}
+
+
+
+ {% if date_filter %}
+
+ {{ date_filter }}
+
+ {% endif %}
+
+
+ {{ 'Tip:'|t }} {{ tip_hover }}
+
+
+
+
+
+
+ {% if date_filter %}
+
+ {{ date_filter }}
+
+ {% endif %}
+
{{ interaction_hint }}
+
+
+ {{ 'Tip:'|t }} {{ tip_taller }}
+
+
+