Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
################
# DrupalCI GitLab CI/CD template.
#
# Project: https://project.pages.drupalcode.org/gitlab_templates/
# Source: https://git.drupalcode.org/project/gitlab_templates/
################

variables:
SKIP_COMPOSER_LINT: '1'
SKIP_PHPCS: '1'
SKIP_PHPSTAN: '1'
SKIP_STYLELINT: '1'
SKIP_ESLINT: '1'
SKIP_CSPELL: '1'

include:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opts into the full DrupalCI matrix, not just Pages: the pages/mkdocs job does live inside include.drupalci.main.yml (and gitlab_templates offers no pages-only include), but SKIP_COMPOSER_LINT, SKIP_PHPCS, SKIP_PHPSTAN, SKIP_STYLELINT, SKIP_ESLINT, and SKIP_CSPELL all default to '0', so every drupalcode push/MR now also runs those jobs with the templates' own configs. This repo has no cspell wordlist and no phpstan config, and standardizes lint on docker compose plus GitHub Actions, so the first pipeline will show unrelated red/orange jobs and any pages-job failure gets lost in the noise. (PHPUnit is safe: that job is gated by a tests-exist rule and this repo has only scripts/e2e/.) Consider setting the documented SKIP_* or allow-failure variables in this same commit, or note explicitly that adopting full DrupalCI is intentional and was verified against a test pipeline. Also worth knowing: the pages job deploys only on pushes to drupalcode's default branch that touch docs/** or mkdocs.yml; nothing deploys from GitHub, MRs, or feature branches.

- project: $_GITLAB_TEMPLATES_REPO
ref: $_GITLAB_TEMPLATES_REF
file:
- '/includes/include.drupalci.main.yml'
- '/includes/include.drupalci.variables.yml'
- '/includes/include.drupalci.workflows.yml'
74 changes: 38 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

# RL: A/B & Multivariate Testing for Drupal (Reinforcement Learning)

**[Full documentation](https://project.pages.drupalcode.org/rl/)** | This README covers the essentials; the docs site has guides, API reference, and worked examples.

A/B and multivariate testing for Drupal using reinforcement learning. Each page
view is a trial, each conversion is a reward, and the algorithm continuously
shifts traffic to whichever variant is winning. RLHF-style feedback loop, no
Expand Down Expand Up @@ -70,26 +72,6 @@ composer require drupal/rl
drush en rl
```

### Plotly.js Library (Required for Charts)

The RL module uses Plotly.js for experiment charts. Install it via Composer using
[Asset Packagist](https://asset-packagist.org):

```bash
# Add Asset Packagist repository (if not already configured)
composer config repositories.asset-packagist composer https://asset-packagist.org

# Install the Composer plugin for npm assets (if not already installed)
composer require oomphinc/composer-installers-extender
composer config extra.installer-types --json '["npm-asset"]'
composer config extra.installer-paths.web/libraries/\{\$name\} --json '["type:npm-asset"]'

# Install Plotly.js
composer require npm-asset/plotly.js-dist-min:^2.35
```

This installs the library to `web/libraries/plotly.js-dist-min/`.

### Post-Installation: Verify rl.php Access

The RL module includes a `.htaccess` file that allows direct access to
Expand All @@ -98,7 +80,7 @@ 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" \
curl -X POST -d "action=ping" \
http://example.com/modules/contrib/rl/rl.php
```

Expand Down Expand Up @@ -134,8 +116,8 @@ location ~ ^/modules/.*\.php$ {
- 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.
If server policies prevent direct access to `rl.php`, your consumer
module must implement its own Drupal route as an alternative endpoint.

## Drush Command Reference

Expand Down Expand Up @@ -234,7 +216,7 @@ $experiment_manager->recordTurn('my-experiment', 'variant-a');
$experiment_manager->recordReward('my-experiment', 'variant-a');

// Get Thompson Sampling scores
$scores = $experiment_manager->getThompsonScores('my-experiment');
$scores = $experiment_manager->getThompsonScores('my-experiment', NULL, ['variant-a', 'variant-b']);

// Select best option
$ts_calculator = \Drupal::service('rl.ts_calculator');
Expand Down Expand Up @@ -410,8 +392,8 @@ Content-Type: application/json
```

All three sections are optional. Invalid or unregistered entries are
dropped silently so one bad event does not poison the rest of the
batch. The response is
rejected individually without poisoning the rest of the batch. A
successful response looks like:

```json
{
Expand All @@ -431,6 +413,27 @@ Sampling lookup. Missing keys mean "use the default variant". When
`ranking[0]` for backwards compatibility. Turns and rewards are
fire-and-forget writes with no per-event response.

When some entries are rejected (invalid IDs, unregistered experiments,
malformed entries), the response includes an `errors` array:

```json
{
"ok": true,
"decisions": {
"hero_cta": {"armId": "v1"}
},
"errors": [
{"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"},
{"kind": "turn", "id": "bad!", "reason": "invalid_id"}
]
}
Comment on lines +416 to +429

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny carry-over: this section got the pre-fix wording, so the example pairs "ok": false with "when some entries are rejected", while rl.php sets ok: false only when every entry was rejected (a partial success is 200 with ok: true plus decisions and errors). docs/api/http.md now has the corrected version; this brings the README in line with it.

Suggested change
When some entries are rejected (invalid IDs, unregistered experiments,
malformed entries), the response includes an `errors` array:
```json
{
"ok": false,
"errors": [
{"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"},
{"kind": "turn", "id": "bad!", "reason": "invalid_id"}
]
}
When some entries are rejected (invalid IDs, unregistered experiments,
malformed entries), the response includes an `errors` array alongside
any successful results. A partial success returns HTTP 200 with
`ok: true`:
```json
{
"ok": true,
"decisions": {
"hero_cta": {"armId": "v1"}
},
"errors": [
{"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"},
{"kind": "turn", "id": "bad!", "reason": "invalid_id"}
]
}
```

```

Each error includes the `kind` (decide, turn, or reward), the `id` that
failed, and a machine-readable `reason`. Partial successes return HTTP 200
with `ok: true` alongside both `decisions` and `errors`; the response is
422 with `ok: false` only when every entry in the batch was rejected.

### Curl examples

```bash
Expand All @@ -449,6 +452,7 @@ curl -X POST 'https://example.com/modules/contrib/rl/rl.php' -d 'action=ping'
| Status | When |
| --- | --- |
| `400` | Missing/invalid `action`, malformed JSON, or missing `experiment_id` on a legacy action. |
| `422` | Batch request where every entry was rejected (all invalid or unregistered). |
| `500` | Drupal kernel failed to boot. Error logged to the PHP error log. |

### Performance notes
Expand Down Expand Up @@ -499,31 +503,29 @@ Implement the `ExperimentDecoratorInterface`:

namespace Drupal\my_module\Decorator;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\rl\Decorator\ExperimentDecoratorInterface;

class MyExperimentDecorator implements ExperimentDecoratorInterface {

/**
* {@inheritdoc}
*/
protected EntityTypeManagerInterface $entityTypeManager;

public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}

public function decorateExperiment(string $experiment_id): ?array {
// Return NULL to skip, or a render array for custom display.
if (!str_starts_with($experiment_id, 'my_module-')) {
return NULL;
}
return ['#markup' => '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);
$entity = $this->entityTypeManager->getStorage('node')->load($arm_id);
if ($entity) {
return [
'#markup' => htmlspecialchars($entity->label()) .
Expand Down
125 changes: 125 additions & 0 deletions docs/api/http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# HTTP API (rl.php)

`rl.php` is the low-level endpoint. It is reachable directly from any
client that can make an HTTP POST: browser pages, native mobile apps,
server-side workers, other CMSes, edge functions. Deciding is *not*
exposed here; it happens in PHP at render time as described in the
[PHP API](php.md).

## Supported actions

All actions are additive; adding `batch` did not deprecate the legacy
form actions, and `rl_sorting` and other production consumers keep
using them unchanged.

| Action | Encoding | Purpose |
| --- | --- | --- |
| `ping` | form POST | Liveness check. Returns `pong`. |
| `turn` | form POST | Record one impression. |
| `turns` | form POST | Record impressions for many arms in one experiment. |
| `reward` | form POST | Record one conversion. |
| `batch` | JSON POST | Record turns and rewards across many experiments in one request. Used by `Drupal.rl`. |

Experiment IDs and arm IDs must match `^[a-zA-Z0-9_-]+$`. Experiments
must already be registered via `ExperimentRegistryInterface::register()`;
unknown IDs are silently dropped so garbage writes cannot create
registry entries.

## Legacy form actions

```bash
# Turn
curl -X POST https://example.com/modules/contrib/rl/rl.php \
-d 'action=turn&experiment_id=hero_cta&arm_id=v0'

# Multiple arms in one experiment
curl -X POST https://example.com/modules/contrib/rl/rl.php \
-d 'action=turns&experiment_id=hero_cta&arm_ids=v0,v1'

# Reward
curl -X POST https://example.com/modules/contrib/rl/rl.php \
-d 'action=reward&experiment_id=hero_cta&arm_id=v0'
```

## Batch action

```
POST /modules/contrib/rl/rl.php?action=batch
Content-Type: application/json

{
"decides": [
{"id": "hero_cta", "arms": ["v0", "v1", "v2"]},
{"id": "faq_sort", "arms": ["t0", "t1", "t2", "t3"], "rank": true}
],
"turns": [
{"id": "hero_cta", "arm": "v0"},
{"id": "menu_main_5", "arm": "v1"}
],
"rewards": [
{"id": "hero_cta", "arm": "v0"}
]
}
```

All three sections are optional. Invalid or unregistered entries are
rejected individually without poisoning the rest of the batch. A
successful response looks like:

```json
{
"ok": true,
"decisions": {
"hero_cta": {"armId": "v1"},
"faq_sort": {"armId": "t2", "ranking": ["t2", "t0", "t3", "t1"]}
}
}
```

`decisions` contains only entries that had a successful Thompson
Sampling lookup. Missing keys mean "use the default variant". When
`"rank": true` is set on a decide entry, the response includes a
`ranking` array with all arm IDs sorted by Thompson Sampling score
(best first). The `armId` field is always present and equals
`ranking[0]` for backwards compatibility. Turns and rewards are
fire-and-forget writes with no per-event response.

When some entries are rejected (invalid IDs, unregistered experiments,
malformed entries), the response includes an `errors` array alongside
any successful results. A partial success returns HTTP 200 with
`ok: true`:

```json
{
"ok": true,
"decisions": {
"hero_cta": {"armId": "v1"}
},
"errors": [
{"kind": "decide", "id": "unknown_exp", "reason": "unknown_experiment"},
{"kind": "turn", "id": "bad!", "reason": "invalid_id"}
]
}
```

Each error includes the `kind` (decide, turn, or reward), the `id` that
failed, and a machine-readable `reason`. When *every* entry in the batch
is rejected, the response is HTTP 422 with `ok: false` and no
`decisions`.

## Error responses

| Status | When |
| --- | --- |
| `400` | Missing/invalid `action`, malformed JSON, or missing `experiment_id` on a legacy action. |
| `422` | Batch request where every entry was rejected (all invalid or unregistered). |
| `500` | Drupal kernel failed to boot. Error logged to the PHP error log. |

## Performance notes

`rl.php` bootstraps a minimal Drupal kernel per request (same pattern as
core's `statistics.php`), not the full stack that would run behind a
normal route. One kernel boot processes the whole batch, so the cheapest
way to use this endpoint is to send as many events as possible in one
request. `Drupal.rl` already does this on the browser side; non-browser
callers should coalesce events similarly when they can.
98 changes: 98 additions & 0 deletions docs/api/javascript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# JavaScript API

Attach the `rl/api` library to make `Drupal.rl` available. It is a thin
transport proxy that coalesces every decide, impression, and conversion
fired on the page into a single batched POST to `rl.php`, so N
experiments on the same page produce one or two requests instead of one
set per experiment.

## Methods

### `Drupal.rl.decide(experimentId, armIds)`

Ask for a decision when the variant needs to be chosen client-side.
The arm list **must** be read from the DOM; see the discipline section below.

```javascript
var container = document.querySelector('[data-rl-experiment="hero_cta"]');
var armIds = container.dataset.rlArms.split(',');
Drupal.rl.decide('hero_cta', armIds).then(function (armId) {
showVariant(armId);
});
```

### `Drupal.rl.rank(experimentId, armIds)`

Ask for a full ranking when you need to sort, not just pick a winner.
Use case: reordering accordion items, FAQ lists, or any sortable
content by visitor engagement.

```javascript
var items = document.querySelector('[data-rl-experiment="faq_sort"]');
var faqArms = items.dataset.rlArms.split(',');
Drupal.rl.rank('faq_sort', faqArms).then(function (sorted) {
// sorted = ['t3', 't0', 't1', 't2'], all arms, best first
sorted.forEach(function (armId) {
items.appendChild(items.querySelector('[data-rl-arm="' + armId + '"]'));
});
});
```

### `Drupal.rl.turn(experimentId, armId)`

Record an impression when the variant becomes visible.

```javascript
Drupal.rl.turn('hero_cta', 'v0');
```

### `Drupal.rl.reward(experimentId, armId)`

Record a conversion when the user clicks, submits, or converts.

```javascript
Drupal.rl.reward('hero_cta', 'v0');
```

### `Drupal.rl.flush()`

Force an immediate flush of the event queue.

```javascript
Drupal.rl.flush();
```

## Batching behaviour

Events accumulate for 500 ms and then flush in one POST. Decide,
turn, and reward events share the same queue and the same request, so
a page with a DXPR Builder variant block plus tracking on other
elements ends up making a single round trip. Buffered tracking events
are also flushed via `navigator.sendBeacon` on `visibilitychange` and
`pagehide` so they survive navigation.

## Discipline for `decide()` and `rank()`

> **Never hardcode arm IDs in JS. Always read them from a DOM
> attribute that the server-side renderer emitted.**

The DOM is downstream of the same server-render pipeline
that produced the decide's context. When the experiment manager adds
or removes a variant, the consumer's page cache is invalidated, the
next render emits the new attribute, and JS picks it up. JS never
asserts what the arm set is; it just echoes whatever the current
cached HTML says. This keeps `Drupal.rl.decide()` and
`Drupal.rl.rank()` drift-free without requiring the RL core to store
arm lists.

The convention your builder uses internally (numeric `v0..vN`, UUIDs,
node IDs, anything matching `^[a-zA-Z0-9_-]+$`) is whatever you emit
into the attribute. The RL core is arm-agnostic.

If the server returns no decision for an experiment (not registered,
no data, network error), the returned promise resolves to `armIds[0]`
so callers never need a `.catch()` for the common path.

`Drupal.rl` is one transport among several. Modules that already ship
their own tracking JS (like `rl_sorting`, which batches turns on its
own 100 ms window and posts them as form data) keep working untouched.
Loading
Loading