diff --git a/config/install/rl.settings.yml b/config/install/rl.settings.yml
index fdaf54b..a6911f7 100644
--- a/config/install/rl.settings.yml
+++ b/config/install/rl.settings.yml
@@ -1,4 +1,4 @@
debug_mode: false
enable_event_log: true
event_log_max_rows: 100000
-chart_line_threshold: 10
+chart_line_threshold: 9
diff --git a/css/rl-charts.css b/css/rl-charts.css
index e910e3a..42db21c 100644
--- a/css/rl-charts.css
+++ b/css/rl-charts.css
@@ -91,21 +91,18 @@
gap: 0.75em;
}
-.rl-presets,
-.rl-axes {
+.rl-filter-group {
display: flex;
align-items: center;
gap: 0.4em;
}
-.rl-presets strong,
-.rl-axes strong {
+.rl-filter-group strong {
color: #555;
white-space: nowrap;
}
-.rl-preset-select,
-.rl-axis-select {
+.rl-filter-select {
padding: 0.25em 0.5em;
background: #fff;
border: 1px solid #ced4da;
@@ -114,13 +111,11 @@
cursor: pointer;
}
-.rl-preset-select:hover,
-.rl-axis-select:hover {
+.rl-filter-select:hover {
border-color: #adb5bd;
}
-.rl-preset-select:focus,
-.rl-axis-select:focus {
+.rl-filter-select:focus {
border-color: #0d6efd;
outline: none;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
diff --git a/js/rl-plotly-charts.js b/js/rl-plotly-charts.js
index cb7a7e8..3131023 100644
--- a/js/rl-plotly-charts.js
+++ b/js/rl-plotly-charts.js
@@ -83,6 +83,13 @@
function initPlotlyCharts(data) {
const config = getResponsiveConfig();
+ // Y-axis metric: 'score' (Bayesian) or 'rate' (raw)
+ const metric = data.metric || 'score';
+ const metricLabel = metric === 'score' ? 'Conversion Score' : 'Conversion Rate';
+ const chartTitle = metric === 'score'
+ ? 'Learning-adjusted Conversion Rate (Conversion Score) Over Time'
+ : 'Conversion Rate Over Time';
+
const defaultLayout = {
paper_bgcolor: 'rgba(255,255,255,1)',
plot_bgcolor: 'rgba(255,255,255,1)',
@@ -94,14 +101,14 @@
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;
+ } else if (data.surface3d && data.surface3d.zMatrixScore) {
+ numArms = data.surface3d.zMatrixScore.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 lineChartThreshold = data.chartLineThreshold || 9;
const showLineChart = numArms >= 1 && numArms <= lineChartThreshold;
const showLandscape = numArms > lineChartThreshold;
@@ -116,7 +123,7 @@
surface3dEl.parentElement.parentElement.style.display = showLandscape ? 'block' : 'none';
}
- // 1. 2D Line Chart - Conversion Rate Over Time (up to threshold arms)
+ // 1. 2D Line Chart (up to threshold arms)
if (showLineChart && data.lineChartData && data.lineChartData.arms && data.lineChartData.arms.length > 0 && lineChartEl) {
try {
const traces2d = [];
@@ -133,7 +140,8 @@
const yValues = [];
arm.data.forEach(function(point) {
xValues.push(point.x);
- yValues.push(point.y);
+ // Use score or rate based on metric setting
+ yValues.push(metric === 'score' ? point.score : point.rate);
});
traces2d.push({
@@ -146,7 +154,7 @@
color: arm.color,
width: 2
},
- hovertemplate: '' + armLabel + '
' + xAxisLabel + ': %{x}
Rate: %{y:.1f}%
' + xAxisLabel + ': %{x}
' + metricLabel + ': %{y:.1f}%
' + xAxisLabel3d + ': ' + xDisplay + '
Rate: ' + rate.toFixed(1) + '%');
+ hoverRow.push('' + fullLabel + '
' + xAxisLabel3d + ': ' + xDisplay + '
' + metricLabel + ': ' + val.toFixed(1) + '%');
}
hoverTextData.push(hoverRow);
}
@@ -260,10 +269,14 @@
tickfont: { size: config.tickSize }
};
- // Show arm labels on Y-axis for small number of arms
- if (landscapeNumArms <= lineChartThreshold) {
+ // Show arm labels on Y-axis when 15 or fewer variants for readability
+ // Use shorter labels for 3D axis (max 25 chars) to ensure proper alignment
+ if (landscapeNumArms <= 15) {
+ const shortLabels = sortedLabels.map(function(label) {
+ return truncateLabel(label, 25);
+ });
yAxisConfig.tickvals = armIndices;
- yAxisConfig.ticktext = truncatedLabels;
+ yAxisConfig.ticktext = shortLabels;
yAxisConfig.tickangle = 0;
}
@@ -346,17 +359,17 @@
z: 100
},
colorbar: {
- title: { text: 'Conversion Rate', side: 'right', font: { size: config.axisTitleSize } },
+ title: { text: metricLabel, 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 } },
+ title: { text: chartTitle, font: { size: config.titleSize } },
scene: {
xaxis: xAxis3dConfig,
yaxis: yAxisConfig,
zaxis: {
- title: { text: 'Conversion Rate (%)', font: { size: config.axisTitleSize } },
+ title: { text: metricLabel + ' (%)', font: { size: config.axisTitleSize } },
tickfont: { size: config.tickSize }
},
camera: {
@@ -373,7 +386,7 @@
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.';
+ tipEl.innerHTML = 'Tip: Showing top ' + landscapeNumArms + ' active variants out of ' + totalArmsAll + ' total. Taller/brighter = better ' + metricLabel.toLowerCase() + '.';
}
}
} catch (e) {
diff --git a/rl.install b/rl.install
index fc9e93b..52e93a2 100644
--- a/rl.install
+++ b/rl.install
@@ -614,7 +614,7 @@ function rl_update_10001() {
function rl_update_10002() {
$config = \Drupal::configFactory()->getEditable('rl.settings');
if ($config->get('chart_line_threshold') === NULL) {
- $config->set('chart_line_threshold', 10);
+ $config->set('chart_line_threshold', 9);
}
$config->save();
diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php
index ad7cb84..3f3de61 100644
--- a/src/Controller/ReportsController.php
+++ b/src/Controller/ReportsController.php
@@ -281,6 +281,8 @@ public function experimentDetail($experiment_id) {
$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';
+ $metric = $request ? $request->query->get('metric', 'score') : 'score';
+ $limit = (int) ($request ? $request->query->get('limit', 100) : 100);
// Validate time axis value.
$valid_axes = ['trials', 'daily', 'weekly', 'monthly', 'quarterly'];
@@ -288,6 +290,18 @@ public function experimentDetail($experiment_id) {
$time_axis = 'trials';
}
+ // Validate metric value.
+ $valid_metrics = ['score', 'rate'];
+ if (!in_array($metric, $valid_metrics)) {
+ $metric = 'score';
+ }
+
+ // Validate limit value.
+ $valid_limits = [5, 10, 25, 50, 75, 100];
+ if (!in_array($limit, $valid_limits)) {
+ $limit = 100;
+ }
+
// Calculate date range from preset if provided.
$date_range = $this->calculateDateRange($preset, $start_date, $end_date);
@@ -297,7 +311,7 @@ public function experimentDetail($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);
+ $date_filter = $this->buildDateFilterForm($experiment_id, $date_range, $available_range, $preset, $time_axis, $metric, $limit);
}
// Add charts if we have snapshot data.
@@ -307,7 +321,7 @@ public function experimentDetail($experiment_id) {
$date_range['end'] ?? NULL
);
if (!empty($snapshots)) {
- $build['charts'] = $this->buildCharts($experiment_id, $snapshots, $arms, $time_axis, $date_filter);
+ $build['charts'] = $this->buildCharts($experiment_id, $snapshots, $arms, $time_axis, $date_filter, $metric, $limit);
}
else {
// Show appropriate message based on whether event logging is enabled.
@@ -333,7 +347,8 @@ public function experimentDetail($experiment_id) {
['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'],
+ ['data' => $this->t('Conversion Rate'), 'field' => 'conversion_rate', 'sort' => 'desc'],
+ ['data' => $this->t('Conversion Score'), 'field' => 'conversion_score'],
];
// Build row data with sortable values.
@@ -342,7 +357,13 @@ public function experimentDetail($experiment_id) {
// Validate and sanitize arm data.
$arm = $this->armDataValidator->validateAndSanitize($arm, $experiment_id, $arm->arm_id);
- $success_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0;
+ // Raw conversion rate.
+ $conversion_rate = $arm->turns > 0 ? ($arm->rewards / $arm->turns) * 100 : 0;
+
+ // Bayesian posterior mean (Conversion Score).
+ $alpha = $arm->rewards + 1;
+ $beta = max(1, $arm->turns - $arm->rewards + 1);
+ $conversion_score = ($alpha / ($alpha + $beta)) * 100;
// Get decorated arm name or fallback to escaped arm ID.
$arm_display = $this->decoratorManager->decorateArm($experiment_id, $arm->arm_id);
@@ -353,24 +374,28 @@ public function experimentDetail($experiment_id) {
'arm_name' => $arm_name,
'turns' => (int) $arm->turns,
'rewards' => (int) $arm->rewards,
- 'success_rate' => $success_rate,
+ 'conversion_rate' => $conversion_rate,
+ 'conversion_score' => $conversion_score,
];
}
// Sort by the selected column.
- $order = $request ? $request->query->get('order', 'Rate') : 'Rate';
+ $order = $request ? $request->query->get('order', 'Conversion Rate') : 'Conversion Rate';
$sort = $request ? $request->query->get('sort', 'desc') : 'desc';
- $sort_field = 'success_rate';
+ $sort_field = 'conversion_rate';
if (stripos($order, 'Variant') !== FALSE) {
$sort_field = 'arm_id';
}
elseif (stripos($order, 'Impression') !== FALSE) {
$sort_field = 'turns';
}
- elseif (stripos($order, 'Conversion') !== FALSE) {
+ elseif (stripos($order, 'Conversions') !== FALSE) {
$sort_field = 'rewards';
}
+ elseif (stripos($order, 'Conversion Rate') !== FALSE) {
+ $sort_field = 'conversion_rate';
+ }
// @phpstan-ignore argument.unresolvableType, argument.unresolvableType
usort($arm_data, static function (array $a, array $b) use ($sort_field, $sort): int {
@@ -385,7 +410,8 @@ public function experimentDetail($experiment_id) {
['data' => ['#markup' => $data['arm_name']]],
$data['turns'],
$data['rewards'],
- number_format($data['success_rate'], 2) . '%',
+ number_format($data['conversion_rate'], 2) . '%',
+ number_format($data['conversion_score'], 2) . '%',
];
}
@@ -414,11 +440,15 @@ public function experimentDetail($experiment_id) {
* or 'quarterly'.
* @param array|null $date_filter
* Optional date filter render array.
+ * @param string $metric
+ * Y-axis metric: 'score' (Bayesian) or 'rate' (raw).
+ * @param int $limit
+ * Maximum number of top variants for 3D chart.
*
* @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 {
+ protected function buildCharts(string $experiment_id, array $snapshots, array $arms, string $time_axis = 'trials', ?array $date_filter = NULL, string $metric = 'score', int $limit = 100): array {
// Organize snapshots by arm and calculate chart data.
$arms_data = [];
$all_x_values = [];
@@ -444,15 +474,19 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
$turns = (int) $snapshot->turns;
$rewards = (int) $snapshot->rewards;
- // Calculate posterior mean.
+ // Calculate posterior mean (Conversion Score).
$alpha = $rewards + 1;
$beta = max(1, $turns - $rewards + 1);
- $mean = $alpha / ($alpha + $beta);
+ $score = $alpha / ($alpha + $beta);
+
+ // Calculate raw conversion rate.
+ $rate = $turns > 0 ? $rewards / $turns : 0;
// 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,
+ 'score' => $score,
+ 'rate' => $rate,
'turns' => $turns,
'rewards' => $rewards,
'created' => $created,
@@ -480,8 +514,8 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
}
arsort($arm_totals);
- // Use up to 100 arms for 3D Plotly visualizations.
- $top_arms_3d = array_slice(array_keys($arm_totals), 0, 100);
+ // Use up to $limit arms for 3D Plotly visualizations.
+ $top_arms_3d = array_slice(array_keys($arm_totals), 0, $limit);
// Build arm label map using decorators for human-readable names.
$arm_labels = [];
@@ -516,8 +550,14 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
];
// Prepare chart data for both 2D line and 3D surface (single loop).
+ // Pass both score and rate so JS can switch between them.
$line_chart_data = ['arms' => []];
- $surface_3d_data = ['xValues' => $x_values, 'armLabels' => [], 'zMatrix' => []];
+ $surface_3d_data = [
+ 'xValues' => $x_values,
+ 'armLabels' => [],
+ 'zMatrixScore' => [],
+ 'zMatrixRate' => [],
+ ];
$i = 0;
foreach ($top_arms_3d as $arm_id) {
if (!isset($arms_data[$arm_id])) {
@@ -526,13 +566,16 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
$label = $arm_labels[$arm_id];
$color = $colors[$i % count($colors)];
$data_points = [];
- $z_row = [];
+ $z_row_score = [];
+ $z_row_rate = [];
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;
+ $score = round($arms_data[$arm_id][$x]['score'] * 100, 2);
+ $rate = round($arms_data[$arm_id][$x]['rate'] * 100, 2);
+ $data_points[] = ['x' => $x, 'score' => $score, 'rate' => $rate];
+ $z_row_score[] = $score;
+ $z_row_rate[] = $rate;
}
else {
// Find closest previous value for 3D surface interpolation.
@@ -542,18 +585,20 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
$closest = $point;
}
}
- $z_row[] = $closest ? round($closest['mean'] * 100, 2) : 0;
+ $z_row_score[] = $closest ? round($closest['score'] * 100, 2) : 0;
+ $z_row_rate[] = $closest ? round($closest['rate'] * 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;
+ $surface_3d_data['zMatrixScore'][] = $z_row_score;
+ $surface_3d_data['zMatrixRate'][] = $z_row_rate;
$i++;
}
// Plotly data (up to 100 arms for 3D visualizations).
- $chart_line_threshold = $this->config('rl.settings')->get('chart_line_threshold') ?? 10;
+ $chart_line_threshold = $this->config('rl.settings')->get('chart_line_threshold') ?? 9;
$total_arms_all = count($arm_totals);
$plotly_data = [
'lineChartData' => $line_chart_data,
@@ -562,6 +607,7 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
'totalArmsAll' => $total_arms_all,
'chartLineThreshold' => $chart_line_threshold,
'timeAxis' => $time_axis,
+ 'metric' => $metric,
'xAxisLabel' => (string) $x_axis_label,
'xLabels' => !empty($x_labels) ? $x_labels : NULL,
];
@@ -581,7 +627,7 @@ protected function buildCharts(string $experiment_id, array $snapshots, array $a
'#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.'),
+ '#tip_taller' => $this->t('Hover for details. Taller/brighter = better conversion score.'),
'#interaction_hint' => $this->t('Drag to rotate @bullet Scroll to zoom', ['@bullet' => '•']),
'#date_filter' => $date_filter,
];
@@ -765,11 +811,15 @@ protected function getTimeAxisLabel(string $time_axis) {
* Currently selected preset.
* @param string $current_axis
* Currently selected time axis.
+ * @param string $current_metric
+ * Currently selected y-axis metric.
+ * @param int $current_limit
+ * Currently selected max variants limit for 3D chart.
*
* @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 {
+ protected function buildDateFilterForm(string $experiment_id, array $current_range, array $available_range, string $current_preset, string $current_axis = 'trials', string $current_metric = 'score', int $current_limit = 100): array {
$base_url = Url::fromRoute('rl.reports.experiment_detail', [
'experiment_id' => $experiment_id,
]);
@@ -797,17 +847,41 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran
'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 {
+ $metrics = [
+ 'score' => $this->t('Conversion Score'),
+ 'rate' => $this->t('Conversion Rate'),
+ ];
+
+ $limits = [
+ 5 => '5',
+ 10 => '10',
+ 25 => '25',
+ 50 => '50',
+ 75 => '75',
+ 100 => '100',
+ ];
+
+ // Helper to build dropdown options with URLs, preserving other params.
+ $build_options = function (array $items, string $param, array $other_params) use ($base_url): array {
$options = $urls = [];
foreach ($items as $key => $label) {
$url = clone $base_url;
- $query = [];
- if ($key && $key !== $other_default) {
+ $query = $other_params;
+ if ($key) {
$query[$param] = $key;
}
- if ($other_value && $other_value !== $other_default) {
- $query[$other_param] = $other_value;
+ // Remove default values from query.
+ if (isset($query['axis']) && $query['axis'] === 'trials') {
+ unset($query['axis']);
+ }
+ if (isset($query['preset']) && $query['preset'] === '') {
+ unset($query['preset']);
+ }
+ if (isset($query['metric']) && $query['metric'] === 'score') {
+ unset($query['metric']);
+ }
+ if (isset($query['limit']) && (int) $query['limit'] === 100) {
+ unset($query['limit']);
}
if (!empty($query)) {
$url->setOption('query', $query);
@@ -818,8 +892,26 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran
return ['options' => $options, 'urls' => $urls];
};
- $preset_data = $build_options($presets, 'preset', 'axis', $current_axis, 'trials');
- $axis_data = $build_options($axes, 'axis', 'preset', $current_preset, '');
+ $preset_data = $build_options($presets, 'preset', [
+ 'axis' => $current_axis,
+ 'metric' => $current_metric,
+ 'limit' => $current_limit,
+ ]);
+ $axis_data = $build_options($axes, 'axis', [
+ 'preset' => $current_preset,
+ 'metric' => $current_metric,
+ 'limit' => $current_limit,
+ ]);
+ $metric_data = $build_options($metrics, 'metric', [
+ 'preset' => $current_preset,
+ 'axis' => $current_axis,
+ 'limit' => $current_limit,
+ ]);
+ $limit_data = $build_options($limits, 'limit', [
+ 'preset' => $current_preset,
+ 'axis' => $current_axis,
+ 'metric' => $current_metric,
+ ]);
// Format available date range for display.
$range_text = $this->t('Data available from @start to @end', [
@@ -832,7 +924,7 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran
'#attributes' => ['class' => ['rl-date-filter']],
'presets' => [
'#type' => 'container',
- '#attributes' => ['class' => ['rl-presets']],
+ '#attributes' => ['class' => ['rl-filter-group']],
'label' => [
'#markup' => '' . $this->t('Time range:') . ' ',
],
@@ -841,14 +933,14 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran
'#options' => $preset_data['options'],
'#value' => $current_preset,
'#attributes' => [
- 'class' => ['rl-preset-select', 'rl-filter-select'],
+ 'class' => ['rl-filter-select'],
'data-urls' => json_encode($preset_data['urls']),
],
],
],
'axes' => [
'#type' => 'container',
- '#attributes' => ['class' => ['rl-axes']],
+ '#attributes' => ['class' => ['rl-filter-group']],
'label' => [
'#markup' => '' . $this->t('X-axis:') . ' ',
],
@@ -857,11 +949,43 @@ protected function buildDateFilterForm(string $experiment_id, array $current_ran
'#options' => $axis_data['options'],
'#value' => $current_axis,
'#attributes' => [
- 'class' => ['rl-axis-select', 'rl-filter-select'],
+ 'class' => ['rl-filter-select'],
'data-urls' => json_encode($axis_data['urls']),
],
],
],
+ 'metrics' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['rl-filter-group']],
+ 'label' => [
+ '#markup' => '' . $this->t('Y-axis:') . ' ',
+ ],
+ 'select' => [
+ '#type' => 'select',
+ '#options' => $metric_data['options'],
+ '#value' => $current_metric,
+ '#attributes' => [
+ 'class' => ['rl-filter-select'],
+ 'data-urls' => json_encode($metric_data['urls']),
+ ],
+ ],
+ ],
+ 'limits' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['rl-filter-group']],
+ 'label' => [
+ '#markup' => '' . $this->t('Max variants:') . ' ',
+ ],
+ 'select' => [
+ '#type' => 'select',
+ '#options' => $limit_data['options'],
+ '#value' => $current_limit,
+ '#attributes' => [
+ 'class' => ['rl-filter-select'],
+ 'data-urls' => json_encode($limit_data['urls']),
+ ],
+ ],
+ ],
'range_info' => [
'#markup' => '