diff --git a/README.md b/README.md index 8ea654d..2f3903f 100644 --- a/README.md +++ b/README.md @@ -40,20 +40,50 @@ drush en rl ### Post-Installation: Verify rl.php Access -The RL module includes a `.htaccess` file that allows direct access to `rl.php` (following the same pattern as Drupal 11's contrib statistics module). Test that it's working: +The RL module includes a `.htaccess` file that allows direct access to +`rl.php` (following the same pattern as Drupal 11's contrib statistics +module). Test that it's working: ```bash # Test if rl.php is accessible -curl -X POST -d "action=turns&experiment_id=test&arm_ids=1" http://example.com/modules/contrib/rl/rl.php +curl -X POST -d "action=turns&experiment_id=test&arm_ids=1" \ + http://example.com/modules/contrib/rl/rl.php ``` **If the test fails:** - **Apache**: Ensure `.htaccess` files are processed (`AllowOverride All`) -- **Nginx**: Copy the rewrite rules from `.htaccess` to your server config +- **Nginx**: Add the configuration rules below to your server block - **Security modules**: Whitelist `/modules/contrib/rl/rl.php` -If server policies prevent direct access to `rl.php`, use the Drupal Routes API instead. +#### Nginx Configuration + +Add these rules to your Nginx server block, **before** the main Drupal location block: + +```nginx +# Allow direct access to rl.php for performance +location ~ ^/modules/contrib/rl/rl\.php$ { + fastcgi_split_path_info ^(.+?\.php)(|/.*)$; + try_files $uri =404; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param QUERY_STRING $query_string; + fastcgi_pass unix:/var/run/php/php-fpm.sock; # Adjust to your PHP-FPM socket +} + +# Block access to other PHP files in modules (except rl.php) +location ~ ^/modules/.*\.php$ { + deny all; +} +``` + +**Note:** Adjust `fastcgi_pass` to match your PHP-FPM configuration: +- Socket: `unix:/var/run/php/php8.1-fpm.sock` (or your PHP version) +- TCP: `127.0.0.1:9000` + +If server policies prevent direct access to `rl.php`, use the Drupal +Routes API instead. ## API Usage @@ -136,6 +166,85 @@ RL provides optional cache management for web components: Full algorithm details available in source code: [ThompsonCalculator.php](https://git.drupalcode.org/project/rl/-/blob/1.x/src/Service/ThompsonCalculator.php) +## Experiment Decorators + +Decorators customize how experiments and arms are displayed in the RL reports +interface. By default, experiments and arms show their raw IDs, but decorators +can provide human-readable labels. + +### Creating a Decorator + +Implement the `ExperimentDecoratorInterface`: + +```php + 'My Custom Experiment Name']; + } + + /** + * {@inheritdoc} + */ + public function decorateArm(string $experiment_id, string $arm_id): ?array { + // Return NULL to skip, or a render array for custom display. + if (!str_starts_with($experiment_id, 'my_module-')) { + return NULL; + } + // Example: Load entity and return its label. + $entity = \Drupal::entityTypeManager()->getStorage('node')->load($arm_id); + if ($entity) { + return [ + '#markup' => htmlspecialchars($entity->label()) . + ' (' . htmlspecialchars($arm_id) . ')', + ]; + } + return NULL; + } + +} +``` + +### Registering the Decorator + +Add the decorator service to your module's `*.services.yml` with the +`rl_experiment_decorator` tag: + +```yaml +services: + my_module.experiment_decorator: + class: Drupal\my_module\Decorator\MyExperimentDecorator + arguments: ['@entity_type.manager'] + tags: + - { name: rl_experiment_decorator } +``` + +The decorator manager automatically discovers all tagged services and calls +them in order until one returns a non-NULL value. + +### Best Practices + +- **Check experiment prefix**: Return `NULL` early for experiments your + decorator doesn't handle. +- **Handle missing entities**: Entities may be deleted; return `NULL` if the + entity can't be loaded. +- **Use render arrays**: Return proper Drupal render arrays for consistent + theming and security. +- **Escape output**: Use `htmlspecialchars()` for any user-provided content. + ## Development ### Linting and Code Standards diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..de8b20e --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "name": "drupal/rl", + "type": "drupal-module", + "description": "Reinforcement Learning module for Drupal using Thompson Sampling.", + "license": "GPL-2.0-or-later", + "require": { + "npm-asset/plotly.js-dist-min": "^2.35" + }, + "extra": { + "installer-paths": { + "libraries/{$name}": ["type:npm-asset"] + } + } +} diff --git a/config/install/rl.settings.yml b/config/install/rl.settings.yml index 9771e04..fdaf54b 100644 --- a/config/install/rl.settings.yml +++ b/config/install/rl.settings.yml @@ -1 +1,4 @@ debug_mode: false +enable_event_log: true +event_log_max_rows: 100000 +chart_line_threshold: 10 diff --git a/config/schema/rl.schema.yml b/config/schema/rl.schema.yml index 588a17c..d966feb 100644 --- a/config/schema/rl.schema.yml +++ b/config/schema/rl.schema.yml @@ -5,3 +5,12 @@ rl.settings: debug_mode: type: boolean label: 'Debug mode' + enable_event_log: + type: boolean + label: 'Enable event log for historical visualization' + event_log_max_rows: + type: integer + label: 'Maximum rows in event log table' + chart_line_threshold: + type: integer + label: 'Maximum arms for 2D line chart' diff --git a/css/rl-charts.css b/css/rl-charts.css new file mode 100644 index 0000000..e910e3a --- /dev/null +++ b/css/rl-charts.css @@ -0,0 +1,131 @@ +/** + * @file + * Styles for RL experiment charts. + */ + +.rl-charts-container { + margin-bottom: 2em; +} + +.rl-chart-row { + display: flex; + flex-wrap: wrap; + gap: 1.5em; + margin-bottom: 1.5em; +} + +.rl-chart-box { + flex: 1 1 100%; + min-width: 0; + background: #f8f9fa; + border: 2px solid #d0d0d0; + border-radius: 8px; + padding: clamp(8px, 1.5vw, 20px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + position: relative; +} + +.rl-chart-filters { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + margin-bottom: 1em; + position: relative; + z-index: 10; +} + +/* Loading spinner for chart regeneration */ +.rl-chart-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: 8px; +} + +.rl-chart-loading::after { + content: ''; + width: 40px; + height: 40px; + border: 4px solid #e9ecef; + border-top-color: #0d6efd; + border-radius: 50%; + animation: rl-spin 0.8s linear infinite; +} + +@keyframes rl-spin { + to { + transform: rotate(360deg); + } +} + +.rl-chart-description { + color: #444; + margin-bottom: 1em; + line-height: 1.5; +} + +.rl-chart-area-2d, +.rl-chart-area-3d { + min-height: 70vh; + max-height: 90vh; + width: 100%; +} + +.rl-chart-tip { + position: relative; + z-index: 10; +} + +/* Date filter styles */ +.rl-date-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75em; +} + +.rl-presets, +.rl-axes { + display: flex; + align-items: center; + gap: 0.4em; +} + +.rl-presets strong, +.rl-axes strong { + color: #555; + white-space: nowrap; +} + +.rl-preset-select, +.rl-axis-select { + padding: 0.25em 0.5em; + background: #fff; + border: 1px solid #ced4da; + border-radius: 4px; + color: #495057; + cursor: pointer; +} + +.rl-preset-select:hover, +.rl-axis-select:hover { + border-color: #adb5bd; +} + +.rl-preset-select:focus, +.rl-axis-select:focus { + border-color: #0d6efd; + outline: none; + box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25); +} + +.rl-range-info { + display: none; +} diff --git a/docker-compose.yml b/docker-compose.yml index 091c2a1..9b6c8e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,6 @@ services: command: bash -c "/src/scripts/run-drupal-check.sh" tty: true environment: - DRUPAL_RECOMMENDED_PROJECT: 11.2.x-dev + DRUPAL_RECOMMENDED_PROJECT: 11.x-dev volumes: - .:/src diff --git a/js/rl-date-filter.js b/js/rl-date-filter.js new file mode 100644 index 0000000..19198c3 --- /dev/null +++ b/js/rl-date-filter.js @@ -0,0 +1,32 @@ +(function (Drupal, once) { + 'use strict'; + + Drupal.behaviors.rlDateFilter = { + attach: function (context) { + const selects = once('rl-date-filter', '.rl-filter-select', context); + + selects.forEach(function (select) { + select.addEventListener('change', function () { + const urls = JSON.parse(this.dataset.urls); + const url = urls[this.value]; + + // Find visible chart boxes and add loading overlay + const chartBoxes = document.querySelectorAll('.rl-chart-box'); + chartBoxes.forEach(function (chartBox) { + if (chartBox.offsetParent !== null) { + const loader = document.createElement('div'); + loader.className = 'rl-chart-loading'; + chartBox.appendChild(loader); + } + }); + + // Navigate after a brief delay to show the spinner + setTimeout(function () { + window.location.href = url; + }, 50); + }); + }); + } + }; + +})(Drupal, once); diff --git a/js/rl-plotly-charts.js b/js/rl-plotly-charts.js new file mode 100644 index 0000000..cb7a7e8 --- /dev/null +++ b/js/rl-plotly-charts.js @@ -0,0 +1,443 @@ +(function (Drupal, drupalSettings, once) { + 'use strict'; + + Drupal.behaviors.rlPlotlyCharts = { + attach: function (context, settings) { + if (!settings.rlPlotly) { + return; + } + + const containers = once('rl-plotly-charts', '.rl-plotly-container', context); + if (!containers.length) { + return; + } + + const data = settings.rlPlotly; + + // Small delay to ensure Plotly is ready + setTimeout(function() { + initPlotlyCharts(data); + }, 200); + } + }; + + /** + * Get the actual height of a chart container element. + */ + function getContainerHeight(elementId) { + const el = document.getElementById(elementId); + if (el) { + const height = el.clientHeight || el.offsetHeight; + // Return at least a minimum height + return Math.max(height, 200); + } + return 400; // Fallback + } + + /** + * Get responsive configuration based on screen width. + * Heights are calculated from container elements, not fixed values. + */ + function getResponsiveConfig() { + const width = window.innerWidth; + const height3d = getContainerHeight('rl-plotly-3d-surface'); + const height2d = getContainerHeight('rl-plotly-2d-lines'); + + // Breakpoint table: [maxWidth, fontSize, titleSize, axisTitleSize, tickSize, + // margins[l,r,t,b], camera[x,y,z], colorbarLen, colorbarThickness, maxLabelLength] + const breakpoints = [ + [430, 10, 13, 11, 9, [50, 30, 40, 60], [2.0, -2.0, 1.2], 0.6, 15, 20], + [768, 11, 14, 12, 10, [60, 35, 45, 80], [1.8, -1.9, 1.0], 0.7, 18, 30], + [1200, 12, 15, 13, 11, [70, 40, 50, 90], [1.7, -1.8, 0.95], 0.75, 20, 40], + [1920, 13, 16, 14, 11, [80, 40, 50, 100], [1.6, -1.8, 0.9], 0.8, 20, 50], + [Infinity, 14, 18, 15, 12, [100, 50, 60, 120], [1.5, -1.7, 0.85], 0.85, 25, 60] + ]; + + const bp = breakpoints.find(function(b) { return width <= b[0]; }); + return { + height: height3d, + height2d: height2d, + fontSize: bp[1], + titleSize: bp[2], + axisTitleSize: bp[3], + tickSize: bp[4], + margin: { l: bp[5][0], r: bp[5][1], t: bp[5][2], b: bp[5][3] }, + camera: { eye: { x: bp[6][0], y: bp[6][1], z: bp[6][2] } }, + colorbarLen: bp[7], + colorbarThickness: bp[8], + maxLabelLength: bp[9] + }; + } + + /** + * Truncate label to max length. + */ + function truncateLabel(label, maxLen) { + if (!label && label !== 0) return 'Variant'; + // Convert to string if not already (handles numeric IDs) + const str = String(label); + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 3) + '...'; + } + + function initPlotlyCharts(data) { + const config = getResponsiveConfig(); + + const defaultLayout = { + paper_bgcolor: 'rgba(255,255,255,1)', + plot_bgcolor: 'rgba(255,255,255,1)', + font: { size: config.fontSize, family: 'Arial, sans-serif' }, + margin: config.margin + }; + + // Determine number of arms and which chart to show + let numArms = 0; + if (data.lineChartData && data.lineChartData.arms) { + numArms = data.lineChartData.arms.length; + } else if (data.surface3d && data.surface3d.zMatrix) { + numArms = data.surface3d.zMatrix.length; + } + + // Chart selection based on arm count (threshold from config or default 10): + // 1-threshold arms: 2D line chart + // threshold+1 arms: 3D Posterior Landscape + const lineChartThreshold = data.chartLineThreshold || 10; + const showLineChart = numArms >= 1 && numArms <= lineChartThreshold; + const showLandscape = numArms > lineChartThreshold; + + // Hide unused chart containers + const lineChartEl = document.getElementById('rl-plotly-2d-lines'); + const surface3dEl = document.getElementById('rl-plotly-3d-surface'); + + if (lineChartEl) { + lineChartEl.parentElement.parentElement.style.display = showLineChart ? 'block' : 'none'; + } + if (surface3dEl) { + surface3dEl.parentElement.parentElement.style.display = showLandscape ? 'block' : 'none'; + } + + // 1. 2D Line Chart - Conversion Rate Over Time (up to threshold arms) + if (showLineChart && data.lineChartData && data.lineChartData.arms && data.lineChartData.arms.length > 0 && lineChartEl) { + try { + const traces2d = []; + const lineChartNumArms = data.lineChartData.arms.length; + const maxLineArms = Math.min(lineChartNumArms, 20); // Limit to 20 arms for readability + const xAxisLabel = data.xAxisLabel || 'Total Impressions'; + + for (let idx = 0; idx < maxLineArms; idx++) { + const arm = data.lineChartData.arms[idx]; + const armLabel = arm.label || ('Variant #' + idx); + const truncatedLabel = truncateLabel(armLabel, config.maxLabelLength); + + const xValues = []; + const yValues = []; + arm.data.forEach(function(point) { + xValues.push(point.x); + yValues.push(point.y); + }); + + traces2d.push({ + type: 'scatter', + mode: 'lines', + name: truncatedLabel, + x: xValues, + y: yValues, + line: { + color: arm.color, + width: 2 + }, + hovertemplate: '' + armLabel + '
' + xAxisLabel + ': %{x}
Rate: %{y:.1f}%' + }); + } + + const lineChartHeight = config.height2d; + + // Configure x-axis based on time axis type + const xAxisConfig = { + title: { text: xAxisLabel, font: { size: config.axisTitleSize } }, + tickfont: { size: config.tickSize }, + gridcolor: 'rgba(0,0,0,0.1)' + }; + + // Use custom tick labels for time-based axes + if (data.xLabels && data.timeAxis !== 'trials') { + const tickVals = Object.keys(data.xLabels).map(Number); + const tickText = Object.values(data.xLabels); + xAxisConfig.tickvals = tickVals; + xAxisConfig.ticktext = tickText; + xAxisConfig.tickangle = -45; + } + + Plotly.newPlot('rl-plotly-2d-lines', traces2d, Object.assign({}, defaultLayout, { + title: { + text: 'Conversion Rate Over Time', + font: { size: config.titleSize } + }, + xaxis: xAxisConfig, + yaxis: { + title: { text: 'Conversion Rate (%)', font: { size: config.axisTitleSize } }, + tickfont: { size: config.tickSize }, + gridcolor: 'rgba(0,0,0,0.1)', + rangemode: 'tozero' + }, + height: lineChartHeight, + showlegend: lineChartNumArms <= lineChartThreshold, + legend: { + orientation: lineChartNumArms <= 5 ? 'v' : 'h', + yanchor: lineChartNumArms <= 5 ? 'top' : 'bottom', + y: lineChartNumArms <= 5 ? 1 : -0.2, + xanchor: 'left', + x: lineChartNumArms <= 5 ? 1.02 : 0, + font: { size: config.tickSize } + }, + hovermode: 'closest' + }), { responsive: true }); + + // Update tip text if showing subset of variants + if (lineChartNumArms > maxLineArms) { + const tipEl = lineChartEl.parentElement.querySelector('.rl-chart-tip'); + if (tipEl) { + tipEl.innerHTML = 'Tip: Showing top ' + maxLineArms + ' active variants out of ' + lineChartNumArms + ' total. Hover for details.'; + } + } + } catch (e) { + console.error('2D line chart error:', e); + } + } + + // 2. 3D Posterior Landscape (loss-landscape style) - threshold+1 arms + if (showLandscape && data.surface3d && data.surface3d.zMatrix && data.surface3d.zMatrix.length > 0 && surface3dEl) { + try { + const landscapeNumArms = data.surface3d.zMatrix.length; + const numTimePoints = data.surface3d.xValues.length; + + // Get current (latest) conversion rate for each arm to sort + const armRates = []; + for (let i = 0; i < landscapeNumArms; i++) { + const lastRate = data.surface3d.zMatrix[i][numTimePoints - 1] || 0; + armRates.push({ index: i, rate: lastRate }); + } + // Sort by rate ASCENDING (lowest rate = lowest index = front, highest rate = back) + armRates.sort(function(a, b) { return a.rate - b.rate; }); + + // Reorder data based on sorted indices + const sortedZMatrix = []; + const sortedLabels = []; + const armLabels = data.surface3d.armLabels || []; + for (let i = 0; i < armRates.length; i++) { + const origIdx = armRates[i].index; + sortedZMatrix.push(data.surface3d.zMatrix[origIdx]); + sortedLabels.push(armLabels[origIdx] || ('Variant #' + origIdx)); + } + + const armIndices = [...Array(landscapeNumArms).keys()]; + + // Build pre-formatted hovertext array (Plotly 3D surfaces don't support %{text} in hovertemplate) + const hoverTextData = []; + const truncatedLabels = []; + const xAxisLabel3d = data.xAxisLabel || 'Impressions'; + + for (let ai = 0; ai < landscapeNumArms; ai++) { + const hoverRow = []; + const fullLabel = sortedLabels[ai] || ('Variant #' + ai); + const displayLabel = truncateLabel(fullLabel, config.maxLabelLength); + truncatedLabels.push(displayLabel); + + for (let ti = 0; ti < numTimePoints; ti++) { + const xValue = data.surface3d.xValues[ti]; + const rate = sortedZMatrix[ai][ti]; + // Use custom label if available for time-based axes + const xDisplay = (data.xLabels && data.xLabels[xValue]) ? data.xLabels[xValue] : xValue; + // Build complete hover text for each point + hoverRow.push('' + fullLabel + '
' + xAxisLabel3d + ': ' + xDisplay + '
Rate: ' + rate.toFixed(1) + '%'); + } + hoverTextData.push(hoverRow); + } + + // Configure Y-axis based on number of arms + const yAxisConfig = { + title: { text: 'Variant', font: { size: config.axisTitleSize } }, + tickfont: { size: config.tickSize } + }; + + // Show arm labels on Y-axis for small number of arms + if (landscapeNumArms <= lineChartThreshold) { + yAxisConfig.tickvals = armIndices; + yAxisConfig.ticktext = truncatedLabels; + yAxisConfig.tickangle = 0; + } + + // Configure X-axis based on time axis type + const xAxis3dConfig = { + title: { text: xAxisLabel3d, font: { size: config.axisTitleSize } }, + tickfont: { size: config.tickSize } + }; + + // Use custom tick labels for time-based axes + if (data.xLabels && data.timeAxis !== 'trials') { + const tickVals3d = Object.keys(data.xLabels).map(Number); + const tickText3d = Object.values(data.xLabels); + xAxis3dConfig.tickvals = tickVals3d; + xAxis3dConfig.ticktext = tickText3d; + } + + // Single pass for all statistics (min, max, sum, sumSq for variance) + let zMin = Infinity, zMax = -Infinity, zSum = 0, zSumSq = 0, zCount = 0; + for (let ai = 0; ai < sortedZMatrix.length; ai++) { + for (let ti = 0; ti < sortedZMatrix[ai].length; ti++) { + const val = sortedZMatrix[ai][ti]; + if (val < zMin) zMin = val; + if (val > zMax) zMax = val; + zSum += val; + zSumSq += val * val; + zCount++; + } + } + const zMean = zSum / zCount; + const zVariance = (zSumSq / zCount) - (zMean * zMean); + const zStdDev = Math.sqrt(Math.max(0, zVariance)); + + // Coefficient of variation: higher = more varied surface + const coeffOfVar = zMean > 0 ? zStdDev / zMean : 0; + + // Ensure minimum range for color differentiation + if (zMax - zMin < 0.1) { + zMax = zMin + 0.1; + } + + // Adaptive lighting based on surface variance + // Low variance (flat surface): high ambient to brighten dark areas + // High variance (ridged surface): moderate ambient, keep good contrast + const varianceFactor = Math.min(1, coeffOfVar * 2); // Normalize to 0-1 + const adaptiveLighting = { + ambient: 0.9 - (varianceFactor * 0.25), // 0.9 for flat, 0.65 for ridged + diffuse: 0.8, // Keep constant + specular: 0.15 + (varianceFactor * 0.1), // 0.15 for flat, 0.25 for ridged + roughness: 0.5, // Keep constant + fresnel: 0.2 // Keep constant + }; + + Plotly.newPlot('rl-plotly-3d-surface', [{ + type: 'surface', + z: sortedZMatrix, + x: data.surface3d.xValues, + y: armIndices, + surfacecolor: sortedZMatrix, + hovertext: hoverTextData, + hoverinfo: 'text', + cmin: zMin, + cmax: zMax, + cauto: false, + colorscale: 'Viridis', + contours: { + z: { + show: true, + usecolormap: true, + highlightcolor: '#ffffff', + project: { z: false } + }, + x: { show: false }, + y: { show: false } + }, + lighting: adaptiveLighting, + lightposition: { + x: 100, + y: 200, + z: 100 + }, + colorbar: { + title: { text: 'Conversion Rate', side: 'right', font: { size: config.axisTitleSize } }, + thickness: config.colorbarThickness, + len: config.colorbarLen + } + }], Object.assign({}, defaultLayout, { + title: { text: 'Conversion Rate Over Time', font: { size: config.titleSize } }, + scene: { + xaxis: xAxis3dConfig, + yaxis: yAxisConfig, + zaxis: { + title: { text: 'Conversion Rate (%)', font: { size: config.axisTitleSize } }, + tickfont: { size: config.tickSize } + }, + camera: { + eye: config.camera.eye, + center: { x: 0, y: 0, z: -0.1 } + }, + aspectratio: { x: 1.5, y: 1, z: 0.8 } + }, + height: config.height + }), { responsive: true }); + + // Update tip text if showing subset of variants + const totalArmsAll = data.totalArmsAll || landscapeNumArms; + if (landscapeNumArms < totalArmsAll) { + const tipEl = surface3dEl.parentElement.querySelector('.rl-chart-tip'); + if (tipEl) { + tipEl.innerHTML = 'Tip: Showing top ' + landscapeNumArms + ' active variants out of ' + totalArmsAll + ' total. Taller/brighter = better conversion rate.'; + } + } + } catch (e) { + console.error('3D posterior landscape error:', e); + } + } + + } + + /** + * Debounce function for performance optimization. + */ + function debounce(func, wait) { + let timeout; + return function executedFunction() { + const context = this; + const args = arguments; + clearTimeout(timeout); + timeout = setTimeout(function() { + func.apply(context, args); + }, wait); + }; + } + + /** + * Track last window dimensions to prevent unnecessary redraws. + */ + let lastWindowWidth = window.innerWidth; + let lastWindowHeight = window.innerHeight; + + /** + * Handle chart resize - only triggers on actual window size changes. + * Uses Plotly.Plots.resize() which is designed for responsive charts. + */ + function handleResize() { + // Only resize if window dimensions actually changed + const currentWidth = window.innerWidth; + const currentHeight = window.innerHeight; + + if (currentWidth === lastWindowWidth && currentHeight === lastWindowHeight) { + return; + } + + lastWindowWidth = currentWidth; + lastWindowHeight = currentHeight; + + const data = drupalSettings.rlPlotly; + if (!data) return; + + // Use Plotly.Plots.resize() for responsive charts - it respects the container + const chartIds = ['rl-plotly-2d-lines', 'rl-plotly-3d-surface']; + + chartIds.forEach(function(chartId) { + const el = document.getElementById(chartId); + if (el && el.data && el.layout) { + Plotly.Plots.resize(el); + } + }); + } + + // Debounced resize handler (300ms delay for performance) + const debouncedResize = debounce(handleResize, 300); + + // Only use window resize event - avoid ResizeObserver to prevent infinite loops + window.addEventListener('resize', debouncedResize, { passive: true }); + +})(Drupal, drupalSettings, once); diff --git a/modules/rl_example/src/Plugin/Block/NewsletterBlock.php b/modules/rl_example/src/Plugin/Block/NewsletterBlock.php index 7a795d8..ffa0b94 100644 --- a/modules/rl_example/src/Plugin/Block/NewsletterBlock.php +++ b/modules/rl_example/src/Plugin/Block/NewsletterBlock.php @@ -138,7 +138,8 @@ public function __construct( /** * {@inheritdoc} */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + // @phpstan-ignore new.static return new static( $configuration, $plugin_id, diff --git a/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php b/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php index 2d419ef..cefcd33 100644 --- a/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php +++ b/modules/rl_example_frontend/src/Plugin/Block/NewsletterBlock.php @@ -115,7 +115,8 @@ public function __construct( /** * {@inheritdoc} */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + // @phpstan-ignore new.static return new static( $configuration, $plugin_id, diff --git a/rl.install b/rl.install index 7eac132..bec40b4 100644 --- a/rl.install +++ b/rl.install @@ -216,6 +216,14 @@ function rl_uninstall() { catch (SchemaObjectDoesNotExistException $e) { // Table doesn't exist, silently continue. } + + // Drop the rl_arm_snapshots table if it exists. + try { + $schema->dropTable('rl_arm_snapshots'); + } + catch (SchemaObjectDoesNotExistException $e) { + // Table doesn't exist, silently continue. + } } /** @@ -359,6 +367,63 @@ function rl_schema() { ], ]; + $schema['rl_arm_snapshots'] = [ + 'description' => 'Stores historical snapshots of arm state for visualization.', + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'experiment_id' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ], + 'arm_id' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ], + 'turns' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Cumulative turns for this arm at snapshot time.', + ], + 'rewards' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Cumulative rewards for this arm at snapshot time.', + ], + 'total_experiment_turns' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Total experiment turns at snapshot time (x-axis).', + ], + 'created' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Unix timestamp when snapshot was created.', + ], + 'is_milestone' => [ + 'type' => 'int', + 'size' => 'tiny', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Whether this is a permanent milestone (1) or recent window (0).', + ], + ], + 'primary key' => ['id'], + 'indexes' => [ + 'experiment_lookup' => ['experiment_id', 'total_experiment_turns'], + ], + ]; + return $schema; } @@ -369,6 +434,27 @@ function rl_requirements($phase) { $requirements = []; if ($phase == 'runtime') { + // Check if Plotly.js library is installed. + $plotly_path = DRUPAL_ROOT . '/libraries/plotly.js-dist-min/plotly.min.js'; + if (!file_exists($plotly_path)) { + $requirements['rl_plotly_library'] = [ + 'title' => t('RL: Plotly.js library'), + 'severity' => REQUIREMENT_ERROR, + 'value' => t('Not installed'), + 'description' => t('The Plotly.js library is required for charts. Install it via Composer: composer require npm-asset/plotly.js-dist-min:^2.35. Your project must have Asset Packagist configured and oomphinc/composer-installers-extender with npm-asset in installer-paths. See the README for details.', [ + '@packagist' => 'https://asset-packagist.org', + '@readme' => 'https://git.drupalcode.org/project/rl/-/blob/1.x/README.md', + ]), + ]; + } + else { + $requirements['rl_plotly_library'] = [ + 'title' => t('RL: Plotly.js library'), + 'severity' => REQUIREMENT_OK, + 'value' => t('Installed'), + ]; + } + $module_handler = \Drupal::service('module_handler'); if ($module_handler->moduleExists('page_cache')) { $requirements['rl_page_cache_conflict'] = [ @@ -381,7 +467,156 @@ function rl_requirements($phase) { ]), ]; } + + // Check if rl.php is accessible via HTTP. + $rl_path = \Drupal::service('extension.list.module')->getPath('rl'); + $base_url = \Drupal::request()->getSchemeAndHttpHost(); + $base_path = \Drupal::request()->getBasePath(); + $rl_endpoint_url = $base_url . $base_path . '/' . $rl_path . '/rl.php'; + + try { + $client = \Drupal::httpClient(); + // Use read-only 'ping' action to verify endpoint accessibility + // without creating any database entries. + $response = $client->post($rl_endpoint_url, [ + 'form_params' => [ + 'action' => 'ping', + ], + 'timeout' => 5, + 'http_errors' => FALSE, + ]); + + $status_code = $response->getStatusCode(); + + if ($status_code === 200) { + $requirements['rl_php_accessibility'] = [ + 'title' => t('RL: rl.php accessibility'), + 'severity' => REQUIREMENT_OK, + 'value' => t('rl.php is accessible (HTTP @code)', ['@code' => $status_code]), + 'description' => t('The rl.php endpoint is correctly configured and accessible at @url.', [ + '@url' => $rl_endpoint_url, + ]), + ]; + } + else { + $requirements['rl_php_accessibility'] = [ + 'title' => t('RL: rl.php accessibility'), + 'severity' => REQUIREMENT_ERROR, + 'value' => t('rl.php returned HTTP @code', ['@code' => $status_code]), + 'description' => t('The rl.php endpoint at @url is not responding correctly. Expected HTTP 200 but got @code. Check your web server configuration (.htaccess for Apache, or add rewrite rules for Nginx). See the README for details.', [ + '@url' => $rl_endpoint_url, + '@code' => $status_code, + '@readme' => 'https://git.drupalcode.org/project/rl/-/blob/1.x/README.md#post-installation-verify-rlphp-access', + ]), + ]; + } + } + catch (\Exception $e) { + $requirements['rl_php_accessibility'] = [ + 'title' => t('RL: rl.php accessibility'), + 'severity' => REQUIREMENT_ERROR, + 'value' => t('rl.php is not accessible'), + 'description' => t('Failed to connect to rl.php at @url. Error: @error. Ensure .htaccess files are being processed (Apache) or rewrite rules are configured (Nginx). See the README for configuration instructions.', [ + '@url' => $rl_endpoint_url, + '@error' => $e->getMessage(), + '@readme' => 'https://git.drupalcode.org/project/rl/-/blob/1.x/README.md#post-installation-verify-rlphp-access', + ]), + ]; + } } return $requirements; } + +/** + * Add rl_arm_snapshots table for event log visualization. + */ +function rl_update_10001() { + $schema = \Drupal::database()->schema(); + + $spec = [ + 'description' => 'Stores historical snapshots of arm state for visualization.', + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'experiment_id' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ], + 'arm_id' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ], + 'turns' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Cumulative turns for this arm at snapshot time.', + ], + 'rewards' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Cumulative rewards for this arm at snapshot time.', + ], + 'total_experiment_turns' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Total experiment turns at snapshot time (x-axis).', + ], + 'created' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'Unix timestamp when snapshot was created.', + ], + 'is_milestone' => [ + 'type' => 'int', + 'size' => 'tiny', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 0, + 'description' => 'Whether this is a permanent milestone (1) or recent window (0).', + ], + ], + 'primary key' => ['id'], + 'indexes' => [ + 'experiment_lookup' => ['experiment_id', 'total_experiment_turns'], + ], + ]; + + if (!$schema->tableExists('rl_arm_snapshots')) { + $schema->createTable('rl_arm_snapshots', $spec); + } + + // Add new config settings. + $config = \Drupal::configFactory()->getEditable('rl.settings'); + if ($config->get('enable_event_log') === NULL) { + $config->set('enable_event_log', TRUE); + } + if ($config->get('event_log_max_rows') === NULL) { + $config->set('event_log_max_rows', 100000); + } + $config->save(); + + return t('Created rl_arm_snapshots table and added event log configuration.'); +} + +/** + * Add chart_line_threshold configuration setting. + */ +function rl_update_10002() { + $config = \Drupal::configFactory()->getEditable('rl.settings'); + if ($config->get('chart_line_threshold') === NULL) { + $config->set('chart_line_threshold', 10); + } + $config->save(); + + return t('Added chart_line_threshold configuration setting.'); +} diff --git a/rl.libraries.yml b/rl.libraries.yml new file mode 100644 index 0000000..2858b7d --- /dev/null +++ b/rl.libraries.yml @@ -0,0 +1,27 @@ +plotly: + version: 2.35.2 + license: + name: MIT + url: https://github.com/plotly/plotly.js/blob/master/LICENSE + gpl-compatible: true + css: + component: + css/rl-charts.css: {} + js: + /libraries/plotly.js-dist-min/plotly.min.js: { minified: true } + js/rl-plotly-charts.js: {} + dependencies: + - core/drupal + - core/drupalSettings + - core/once + +date-filter: + version: 1.0 + css: + component: + css/rl-charts.css: {} + js: + js/rl-date-filter.js: {} + dependencies: + - core/drupal + - core/once diff --git a/rl.links.menu.yml b/rl.links.menu.yml index 5539cdc..5530822 100644 --- a/rl.links.menu.yml +++ b/rl.links.menu.yml @@ -1,13 +1,12 @@ rl.reports.experiments: - title: 'RL Experiments' + title: 'Reinforcement Learning' description: 'View reinforcement learning experiments and their statistics.' route_name: rl.reports.experiments parent: system.admin_reports - weight: 10 rl.settings: - title: 'RL Settings' + title: 'Reinforcement Learning' description: 'Configure reinforcement learning module settings.' route_name: rl.settings - parent: system.admin_config_development + parent: system.admin_config_services weight: 10 diff --git a/rl.module b/rl.module index 18c26df..f21ebc2 100644 --- a/rl.module +++ b/rl.module @@ -7,6 +7,39 @@ use Drupal\Core\Routing\RouteMatchInterface; +/** + * Implements hook_cron(). + */ +function rl_cron() { + // Clean up old snapshots if event logging is enabled. + /** @var \Drupal\rl\Storage\SnapshotStorageInterface $snapshot_storage */ + $snapshot_storage = \Drupal::service('rl.snapshot_storage'); + if ($snapshot_storage->isEnabled()) { + $deleted = $snapshot_storage->cleanup(); + if ($deleted > 0) { + \Drupal::logger('rl')->info('Cleaned up @count old snapshots.', ['@count' => $deleted]); + } + } +} + +/** + * Implements hook_theme(). + */ +function rl_theme() { + return [ + 'rl_charts' => [ + 'variables' => [ + 'title' => NULL, + 'tip_hover' => NULL, + 'tip_taller' => NULL, + 'interaction_hint' => NULL, + 'date_filter' => NULL, + ], + 'template' => 'rl-charts', + ], + ]; +} + /** * Implements hook_help(). */ diff --git a/rl.php b/rl.php index d9383d2..2b49102 100644 --- a/rl.php +++ b/rl.php @@ -15,6 +15,12 @@ $experiment_id = filter_input(INPUT_POST, 'experiment_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS); $arm_id = filter_input(INPUT_POST, 'arm_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS); +// Ping action is read-only and doesn't require experiment_id. +if ($action === 'ping') { + http_response_code(200); + exit('pong'); +} + if (!$action || !$experiment_id || !in_array($action, ['turn', 'turns', 'reward'])) { http_response_code(400); exit('Invalid request parameters'); diff --git a/rl.routing.yml b/rl.routing.yml index d419c9f..5c72913 100644 --- a/rl.routing.yml +++ b/rl.routing.yml @@ -42,7 +42,7 @@ rl.reports.experiments: path: '/admin/reports/rl' defaults: _controller: '\Drupal\rl\Controller\ReportsController::experimentsOverview' - _title: 'RL Experiments' + _title: 'Reinforcement Learning' requirements: _permission: 'view rl reports' @@ -50,7 +50,7 @@ rl.reports.experiment_detail: path: '/admin/reports/rl/experiment/{experiment_id}' defaults: _controller: '\Drupal\rl\Controller\ReportsController::experimentDetail' - _title: 'RL Experiment Detail' + _title: 'Experiment Detail' requirements: _permission: 'view rl reports' experiment_id: '.+' @@ -59,7 +59,7 @@ rl.experiment.add: path: '/admin/reports/rl/add' defaults: _form: '\Drupal\rl\Form\ExperimentForm' - _title: 'Add RL Experiment' + _title: 'Add Experiment' requirements: _permission: 'administer rl experiments' @@ -67,7 +67,7 @@ rl.experiment.edit: path: '/admin/reports/rl/experiment/{experiment_id}/edit' defaults: _form: '\Drupal\rl\Form\ExperimentForm' - _title: 'Edit RL Experiment' + _title: 'Edit Experiment' requirements: _permission: 'administer rl experiments' experiment_id: '.+' @@ -76,15 +76,23 @@ rl.experiment.delete: path: '/admin/reports/rl/experiment/{experiment_id}/delete' defaults: _form: '\Drupal\rl\Form\ExperimentDeleteForm' - _title: 'Delete RL Experiment' + _title: 'Delete Experiment' requirements: _permission: 'administer rl experiments' experiment_id: '.+' rl.settings: - path: '/admin/config/ai/rl' + path: '/admin/config/services/reinforcement-learning' defaults: _form: '\Drupal\rl\Form\RlSettingsForm' - _title: 'RL Settings' + _title: 'Reinforcement Learning' + requirements: + _permission: 'administer site configuration' + +rl.settings.disable_event_log: + path: '/admin/config/services/reinforcement-learning/disable-event-log' + defaults: + _form: '\Drupal\rl\Form\DisableEventLogConfirmForm' + _title: 'Disable Event Log' requirements: _permission: 'administer site configuration' diff --git a/rl.services.yml b/rl.services.yml index f68accb..e488cc2 100644 --- a/rl.services.yml +++ b/rl.services.yml @@ -3,9 +3,17 @@ services: parent: logger.channel_base arguments: ['rl'] + rl.snapshot_storage: + class: Drupal\rl\Storage\SnapshotStorage + arguments: ['@database', '@datetime.time', '@config.factory'] + rl.experiment_data_storage: class: Drupal\rl\Storage\ExperimentDataStorage - arguments: ['@database', '@datetime.time'] + arguments: ['@database', '@datetime.time', '@rl.snapshot_storage'] + + rl.arm_data_validator: + class: Drupal\rl\Service\ArmDataValidator + arguments: ['@logger.channel.rl'] rl.ts_calculator: class: Drupal\rl\Service\ThompsonCalculator @@ -13,7 +21,7 @@ services: rl.experiment_manager: class: Drupal\rl\Service\ExperimentManager - arguments: ['@rl.experiment_data_storage', '@rl.ts_calculator', '@config.factory', '@database', '@logger.factory'] + arguments: ['@rl.experiment_data_storage', '@rl.ts_calculator', '@config.factory', '@database', '@logger.factory', '@rl.arm_data_validator'] rl.experiment_registry: class: Drupal\rl\Registry\ExperimentRegistry diff --git a/scripts/run-drupal-check.sh b/scripts/run-drupal-check.sh index 41ee929..17278dc 100755 --- a/scripts/run-drupal-check.sh +++ b/scripts/run-drupal-check.sh @@ -1,38 +1,43 @@ #!/bin/bash + set -vo pipefail -DRUPAL_RECOMMENDED_PROJECT=${DRUPAL_RECOMMENDED_PROJECT:-10.3.x-dev} -PHP_EXTENSIONS="gd" -DRUPAL_CHECK_TOOL="mglaman/drupal-check" - -# Install required PHP extensions -for ext in $PHP_EXTENSIONS; do - if ! php -m | grep -q $ext; then - apk update && apk add --no-cache ${ext}-dev - docker-php-ext-install $ext - fi -done - -# Create Drupal project if it doesn't exist -if [ ! -d "/drupal" ]; then - composer create-project drupal/recommended-project=$DRUPAL_RECOMMENDED_PROJECT drupal --no-interaction --stability=dev +# Install required libs for Drupal +GD_ENABLED=$(php -i | grep 'GD Support' | awk '{ print $4 }') + +if [ "$GD_ENABLED" != 'enabled' ]; then + apk update && \ + apk add libpng libpng-dev libjpeg-turbo-dev libwebp-dev zlib-dev libxpm-dev gd tree rsync && docker-php-ext-install gd fi -cd drupal -mkdir -p web/modules/contrib/ +# Create project in a temporary directory inside the container +INSTALL_DIR="/drupal_install_tmp" +composer create-project drupal/recommended-project:11.x-dev "$INSTALL_DIR" --no-interaction --stability=dev -# Symlink analyze_ai_brand_voice if not already linked -if [ ! -L "web/modules/contrib/analyze_ai_brand_voice" ]; then - ln -s /src web/modules/contrib/analyze_ai_brand_voice -fi +cd "$INSTALL_DIR" + +# Allow specific plugins needed by dependencies before requiring them. +composer config --no-plugins allow-plugins.tbachert/spi true --no-interaction + +# Create phpstan.neon config file +cat < 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 }} +
+
+