diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5b27e77 --- /dev/null +++ b/.gitlab-ci.yml @@ -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: + - project: $_GITLAB_TEMPLATES_REPO + ref: $_GITLAB_TEMPLATES_REF + file: + - '/includes/include.drupalci.main.yml' + - '/includes/include.drupalci.variables.yml' + - '/includes/include.drupalci.workflows.yml' diff --git a/README.md b/README.md index 50c5fbc..04f537f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 ``` @@ -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 @@ -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'); @@ -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 { @@ -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"} + ] +} +``` + +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 @@ -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 @@ -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()) . diff --git a/docs/api/http.md b/docs/api/http.md new file mode 100644 index 0000000..eb787e2 --- /dev/null +++ b/docs/api/http.md @@ -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. diff --git a/docs/api/javascript.md b/docs/api/javascript.md new file mode 100644 index 0000000..1f3fd9d --- /dev/null +++ b/docs/api/javascript.md @@ -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. diff --git a/docs/api/php.md b/docs/api/php.md new file mode 100644 index 0000000..be96732 --- /dev/null +++ b/docs/api/php.md @@ -0,0 +1,62 @@ +# PHP API + +## Experiment manager + +The `rl.experiment_manager` service is the primary entry point for recording +interactions and retrieving scores. + +```php +$experiment_manager = \Drupal::service('rl.experiment_manager'); + +// Record a trial (content shown to visitor). +$experiment_manager->recordTurn('my-experiment', 'variant-a'); + +// Record a success (visitor clicked/converted). +$experiment_manager->recordReward('my-experiment', 'variant-a'); + +// Get Thompson Sampling scores for specific arms. +$scores = $experiment_manager->getThompsonScores( + 'my-experiment', + NULL, + ['variant-a', 'variant-b', 'variant-c'], +); + +// Select the best option. +$ts_calculator = \Drupal::service('rl.ts_calculator'); +$best_option = $ts_calculator->selectBestArm($scores); +``` + +## Experiment registry + +Experiments must be registered before they can receive data. Unknown +experiment IDs sent to `rl.php` are silently dropped. + +```php +$registry = \Drupal::service('rl.experiment_registry'); + +// Register a new experiment. +$registry->register('my-experiment', 'my_module', 'Homepage Hero Test'); + +// Check if an experiment is registered. +$registered = $registry->isRegistered('my-experiment'); + +// Remove an experiment and all its data. +$experiment_manager = \Drupal::service('rl.experiment_manager'); +$experiment_manager->purgeExperiment('my-experiment'); +``` + +## Variant selection helpers + +For modules that follow the "v0 = original, v1..vN = stored variants" +convention, the module provides reusable traits and base classes: + +- `VariantArmsTrait`: arm-id helpers (`getArmIds`, `getArmText`, + `buildVariantExperimentId`) +- `VariantSelectorBase`: reusable base class for variant selectors +- `VariantParser`: static helper for parsing textarea variant input into + normalised lists + +## Cache management + +See the [cache management guide](../guides/cache.md) for controlling page +cache lifetimes on pages running experiments. diff --git a/docs/development/contributing.md b/docs/development/contributing.md new file mode 100644 index 0000000..68c3da5 --- /dev/null +++ b/docs/development/contributing.md @@ -0,0 +1,79 @@ +# Contributing + +## Issue queue + +Report bugs and feature requests on the +[drupal.org issue queue](https://www.drupal.org/project/issues/rl). + +## Source code + +- [git.drupalcode.org/project/rl](https://git.drupalcode.org/project/rl) (primary) +- [github.com/dxpr/rl](https://github.com/dxpr/rl) (mirror) + +Pull requests are accepted on GitHub and mirrored to drupal.org. + +## Branch conventions + +- `1.x`: main development branch +- Feature branches: `feat/` or `feature/` +- Bug fixes: `fix/` + +## Coding standards + +The project enforces Drupal coding standards and ESLint for JavaScript. +All checks run automatically on pull requests. + +### PHP (Drupal coding standards) + +```bash +docker compose --profile lint run --rm drupal-lint +``` + +Auto-fix coding standard violations: + +```bash +docker compose --profile lint run --rm drupal-lint-auto-fix +``` + +### Drupal compatibility + +```bash +docker compose --profile lint run --rm drupal-check +``` + +### JavaScript (ESLint) + +```bash +npm ci +npx eslint . +``` + +## Running tests + +The project uses end-to-end tests via Drush commands against a real Drupal +site. To run the full test suite locally: + +```bash +docker compose --profile test run --rm e2e-test +``` + + + +## Architecture overview + +The module is built around these core services: + +- `rl.experiment_manager`: records turns (impressions) and rewards (conversions) +- `rl.ts_calculator`: Thompson Sampling algorithm implementation +- `rl.experiment_registry`: experiment registration and lookup +- `rl.cache_manager`: page cache lifetime management +- `rl.analyzer`: experiment analysis and recommendations + +Consumer modules (like `rl_sorting`, `rl_page_title`, `rl_menu_link`) extend +the core by registering experiments and implementing variant selectors and +decorators. Each consumer provides an experiment entity, a selector service +extending `VariantSelectorBase`, a report decorator, and tracking JS. See the +[Building a Consumer Module](../guides/integrations.md) guide for a detailed +walkthrough using `rl_page_title` and `rl_menu_link` as worked examples. + + diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..bc05001 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,74 @@ +# Configuration + +## Module settings + +After enabling the module, visit **Administration > Configuration > +Web services > RL: A/B Testing** +(`/admin/config/services/reinforcement-learning`) to configure the module. + + + +You can also manage settings via Drush: + +```bash +# List all settings with current values +drush rl:config:list + +# Get a specific setting +drush rl:config:get debug_mode + +# Set a specific setting +drush rl:config:set event_log_max_rows 50000 +``` + +See the [Drush commands guide](../guides/drush.md) for the full reference. + +## Choosing a variant selection strategy + +RL supports two approaches for deciding which variant to show. Pick the one +that fits your caching setup. + +### Server-side (preferred) + +Pick the winning variant in PHP at render time whenever you can. Your +consumer already has the full arm set in hand (entity fields, view +filters, plugin config), so it can call the RL service directly: + +```php +$scores = $experiment_manager->getThompsonScores( + 'my_experiment', + NULL, + ['v0', 'v1', 'v2'], +); +arsort($scores); +$best_arm = key($scores); +``` + +Deciding server-side keeps the arm list where it belongs (with the +experiment owner) and avoids a network round trip on every page load. +See `rl_sorting`'s Views sort plugin for the canonical pattern and +`VariantSelectorBase` in this module for a reusable base class. The +[Building a Consumer Module](../guides/integrations.md) guide walks +through both bundled submodules as worked examples. + +### Client-side (cache-friendly path) + +Some consumers have to decide in JS: full-page-cached builders that +render all variants into the HTML and swap them on the client so they +can keep Varnish/Fastly caching. For that case there is +`Drupal.rl.decide()`, documented in the [JavaScript API](../api/javascript.md). + +## Viewing reports + +Once experiments are running and collecting data, visit +**Administration > Reports > RL** (`/admin/reports/rl`) to see +per-experiment performance, traffic distribution, and confidence levels. + +![RL experiment list showing impressions, conversions, and variant counts](../images/rl-experiment-list.jpg) + +Click **View** on any experiment to see its detail page with interactive +charts. The 2D line chart shows conversion scores over time; the 3D +posterior landscape visualises how Thompson Sampling distributions evolve +across variants and impressions. + +![3D Posterior Landscape chart for experiment detail](../images/rl-3d-posterior-landscape.jpg) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..6a0b818 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,60 @@ +# Installation + +## Install the module + +```bash +composer require drupal/rl +drush en rl +``` + +After enabling, visit `/admin/config/services/reinforcement-learning` to +review the default settings, then see [Configuration](configuration.md) +for next steps. + +## 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: + +```bash +curl -X POST -d "action=ping" http://example.com/modules/contrib/rl/rl.php +``` + +**If the test fails:** + +- **Apache**: ensure `.htaccess` files are processed (`AllowOverride All`) +- **Nginx**: add the configuration rules below to your server block +- **Security modules**: whitelist `/modules/contrib/rl/rl.php` + +Direct access to `rl.php` is required for the tracking endpoint. If your +server configuration prevents it, you will need to add a custom route in +your own module that proxies requests to the RL services. + +### 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` diff --git a/docs/guides/ai-assistant.md b/docs/guides/ai-assistant.md new file mode 100644 index 0000000..77b35bb --- /dev/null +++ b/docs/guides/ai-assistant.md @@ -0,0 +1,39 @@ +# AI assistant integration + +The RL module ships with a built-in skill file +(`.claude/skills/rl/SKILL.md`) that teaches AI coding assistants how +to manage experiments through natural language. Compatible with Claude Code, +Codex CLI, Gemini CLI, GitHub Copilot, Cursor, and any tool supporting the +[Agent Skills standard](https://agentskills.io/specification). + +## Quick setup + +```bash +# Install skill files for AI tool discovery +drush rl:setup-ai + +# Claude Code only +drush rl:setup-ai --host=claude + +# Codex/Gemini/Copilot/Cursor only +drush rl:setup-ai --host=agents +``` + +## Example prompts + +After installation, use the `/rl` slash command or ask naturally: + +``` +/rl list all experiments +/rl analyze hero_cta_test +/rl create experiment homepage_banner --module=my_module +``` + +Your assistant will respond to prompts like: + +- "List all running A/B tests" +- "Analyse the hero_cta_test experiment" +- "Create a new A/B test for the homepage banner" +- "What's the conversion rate for variant B?" + + diff --git a/docs/guides/cache.md b/docs/guides/cache.md new file mode 100644 index 0000000..864eeaa --- /dev/null +++ b/docs/guides/cache.md @@ -0,0 +1,42 @@ +# Cache management + +RL provides optional cache management for web components that run A/B tests. +When an experiment needs to refresh more often than the site's page cache +allows, the cache manager shortens the cache lifetime for that page only. + +## Usage + +```php +// Override page cache if experiment cache is shorter than site cache. +\Drupal::service('rl.cache_manager')->overridePageCacheIfShorter(30); +``` + +Call this in your module's render logic, before the response is sent. + +## How it works + +The cache manager compares your requested lifetime against the site's +configured page cache maximum age: + +- If site cache is 300s and experiment needs 30s: overrides to 30s +- If site cache is 60s and experiment needs 300s: leaves at 60s +- If site cache is disabled (0): no override + +The cache manager only shortens the page cache lifetime; it never extends +it beyond what the site already has configured. + +## When to use it + +- Views plugins using RL for content sorting (e.g. `rl_sorting`) +- Blocks displaying A/B tested content +- Components needing frequent RL score updates + +## Client-side alternative + +If you need full page caching (Varnish, Fastly, CDN) and still want +per-visitor variant selection, use the client-side approach instead: +render all variants into the HTML, cache the full page, and let +`Drupal.rl.decide()` swap variants in JavaScript after load. See the +[JavaScript API](../api/javascript.md) for details. + + diff --git a/docs/guides/decorators.md b/docs/guides/decorators.md new file mode 100644 index 0000000..000462d --- /dev/null +++ b/docs/guides/decorators.md @@ -0,0 +1,85 @@ +# Experiment decorators + +Decorators customise 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 +namespace Drupal\my_module\Decorator; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\rl\Decorator\ExperimentDecoratorInterface; + +/** + * Decorates experiments owned by my_module. + */ +class MyExperimentDecorator implements ExperimentDecoratorInterface { + + /** + * Constructs a MyExperimentDecorator. + */ + public function __construct( + protected readonly EntityTypeManagerInterface $entityTypeManager, + ) {} + + /** + * {@inheritdoc} + */ + public function decorateExperiment(string $experiment_id): ?array { + 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 { + if (!str_starts_with($experiment_id, 'my_module-')) { + return NULL; + } + $entity = $this->entityTypeManager->getStorage('node')->load($arm_id); + if ($entity) { + $label = htmlspecialchars($entity->label()); + $id = htmlspecialchars($arm_id); + return [ + '#markup' => $label . ' (' . $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 does not handle. +- **Handle missing entities**: entities may be deleted; return `NULL` if the + entity cannot be loaded. +- **Use render arrays**: return proper Drupal render arrays for consistent + theming and security. +- **Escape output**: use `htmlspecialchars()` for any user-provided content. diff --git a/docs/guides/drush.md b/docs/guides/drush.md new file mode 100644 index 0000000..cee6849 --- /dev/null +++ b/docs/guides/drush.md @@ -0,0 +1,56 @@ +# Drush commands + +## Discovery and analysis + +| Command | Alias | Description | +|---------|-------|-------------| +| `rl:list` | `rll` | Lists all experiments with summary stats | +| `rl:status ` | `rlst` | Detailed status: phase, confidence, traffic distribution | +| `rl:performance ` | `rlp` | Arm-level performance with human-readable labels | +| `rl:trends ` | `rlt` | Historical trends with period aggregation | +| `rl:export ` | `rle` | Exports complete experiment data | +| `rl:analyze ` | `rla` | Full analysis with actionable recommendations | + +## Experiment lifecycle + +| Command | Alias | Description | +|---------|-------|-------------| +| `rl:experiment:create ` | `rl-ec` | Creates experiment (`--module`, `--name`, `--dry-run`) | +| `rl:experiment:update ` | `rl-eu` | Updates name/module (`--module`, `--name`, `--dry-run`) | +| `rl:experiment:delete ` | `rl-ed` | Deletes experiment + all data (`--dry-run`) | + +## Configuration + +| Command | Alias | Description | +|---------|-------|-------------| +| `rl:config:get [key]` | `rl-cg` | Gets one or all settings | +| `rl:config:set ` | `rl-cs` | Sets a setting with validation (`--dry-run`) | +| `rl:config:list` | `rl-cl` | Lists all settings with current values | +| `rl:config:reset` | `rl-cr` | Resets all settings to defaults (`--dry-run`) | +| `rl:event-log:clear` | `rl-elc` | Clears all snapshots (`--dry-run`) | + +## Setup + +| Command | Alias | Description | +|---------|-------|-------------| +| `rl:setup-ai` | `rl-sa` | Installs AI skill files for Claude Code (`--host`, `--check`) | + +## Examples + +```bash +# Create a new A/B test +drush rl:experiment:create hero_cta_test \ + --module=my_module \ + --name="Hero CTA Button Color Test" + +# Check status after data collection +drush rl:status hero_cta_test + +# Get detailed performance breakdown +drush rl:performance hero_cta_test --format=yaml + +# Full analysis with recommendations +drush rl:analyze hero_cta_test +``` + + diff --git a/docs/guides/faq.md b/docs/guides/faq.md new file mode 100644 index 0000000..bd62e33 --- /dev/null +++ b/docs/guides/faq.md @@ -0,0 +1,64 @@ +# FAQ + +## Does RL store my A/B test's variants? + +**It stores their performance data, not the authoritative list.** Every variant +that has received traffic has a row in `rl_arm_data` with turn and reward +counts. But "which variants are in play right now" is owned by your module, +not RL. + +Different consumer modules keep the live variant list in different places: + +- **rl_sorting**: the content returned by a View +- **rl_page_title**: fields on a content entity +- **rl_menu_link**: labels on a menu link +- **DXPR Builder**: slots inside a block component + +On each call your module passes its current list +(`getThompsonScores($id, NULL, $arms)` in PHP or +`Drupal.rl.decide(id, arms)` in JS) and RL matches it against the stored +stats to pick a winner. A newly added variant is in play on the next render; +a removed one stops appearing. No second save step can drift out of sync +with your module's UI. + +## When do I pick a winner and end an A/B test? + +**Only when you want to.** RL has no fixed horizon and no significance gate +to wait out. It just shifts traffic to whatever variant is winning right now +and keeps adapting as evidence changes. + +Two patterns, depending on what you are testing: + +- **Converging tests**: a better page title, a clearer checkout button, a + stronger hero image. Once the report shows a confident winner, lock it in + and move on. +- **Evergreen experiments**: blog post lists, banner ads that fade as + returning visitors tune them out, seasonal calls to action. Leave them + running. RL follows the winner as it shifts. + +In both cases the loser of a pair receives progressively less traffic as +the winner's distribution strengthens, but it never drops to zero: Thompson +Sampling preserves exploration, so a losing arm always retains a small +chance of selection. Traffic only truly stops when the experiment owner +removes the variant. If you are used to fixed-horizon A/B tools, +this is the biggest mental shift: there is no "test complete" flag to chase. + +## How many variants can I run at once? + +There is no hard limit. Thompson Sampling scales to thousands of arms per +experiment. The practical limit is how many variants you can create and +maintain. Two variants is a classic A/B test; dozens or hundreds is true +multivariate testing. + +## Does RL work with page caching? + +Yes. For server-side decisions, the cache lifetime determines how often +the algorithm re-evaluates. For client-side decisions (using `Drupal.rl`), +the page can be fully cached and the variant swap happens in JavaScript +after load. See the [cache management guide](cache.md) for details. + +## Does RL set cookies or track users? + +No. RL tracks only anonymous interaction counts (impressions and +conversions). It does not set cookies, store user IDs, or create visitor +profiles. This makes it GDPR-friendly by design. diff --git a/docs/guides/integrations.md b/docs/guides/integrations.md new file mode 100644 index 0000000..4108fab --- /dev/null +++ b/docs/guides/integrations.md @@ -0,0 +1,357 @@ +# Building a consumer module + +RL is a framework; on its own it stores experiment data and runs Thompson +Sampling. The actual "what gets tested" lives in consumer modules. This guide +walks through the architecture consumer modules share, using the two bundled +submodules (`rl_page_title` and `rl_menu_link`) as worked examples. + +## Integration patterns at a glance + +| Pattern | Server-side (recommended) | Client-side | +| --- | --- | --- | +| **Decision** | PHP at render time via `VariantSelectorBase` | JS via `Drupal.rl.decide()` after page load | +| **Turn** | JS on viewport entry or page load via `Drupal.rl.turn()` | Same | +| **Reward** | JS on conversion event via `Drupal.rl.reward()`, or PHP via `ExperimentManager::recordReward()` | Same | +| **Caching** | `CacheManager::overridePageCacheIfShorter()` ensures variants rotate | Full-page cache safe; all variants rendered, swapped client-side | +| **Examples** | `rl_page_title`, `rl_menu_link`, `rl_sorting` | DXPR Builder | + +## Anatomy of a consumer module + +A server-side consumer module typically provides five pieces: + +1. **Experiment entity**: a content entity implementing `VariantExperimentInterface` +2. **Variant selector service**: a service extending `VariantSelectorBase` +3. **Report decorator service**: a service extending `VariantExperimentDecoratorBase` +4. **Hook integration**: hooks that intercept Drupal's render pipeline and swap content +5. **Tracking JS**: a small Drupal behaviour that records turns and rewards via `Drupal.rl` + +### 1. Experiment entity + +The experiment entity holds the variant data. Both `rl_page_title` and +`rl_menu_link` follow the same pattern: a content entity with +`VariantExperimentInterface` and `VariantArmsTrait`. + +Key fields shared across both implementations: + +- `label`: human-readable name for admin UIs and reports +- `variants_data`: JSON-encoded list of variant text strings +- `lookup_hash`: computed `sha256(target|langcode)` backed by a UNIQUE index +- `enabled`: publish/unpublish toggle (via `EntityPublishedTrait`) +- `langcode`: scopes the experiment per language + +The arm convention enforced by `VariantArmsTrait`: + +- `v0` = original text (read live from the source, never stored) +- `v1`..`vN` = stored variant texts, indexed sequentially + +Each entity class provides two static helpers: + +- `computeLookupHash(target, langcode)`: the UNIQUE index key for O(1) runtime lookup +- `buildRlExperimentId(target, langcode)` (via `VariantArmsTrait::buildVariantExperimentId()`): + a deterministic `{module}-{12-char-sha1}` hash used as the key into RL's + analytics tables + +**rl_page_title** targets a normalised internal path (e.g. `/node/42`): + +```php +$fields['path'] = BaseFieldDefinition::create('string') + ->setLabel(t('Page URL or path')) + ->setRequired(TRUE) + ->setSetting('max_length', 2048); +``` + +**rl_menu_link** targets a menu link plugin ID (e.g. +`menu_link_content:some-uuid`): + +```php +$fields['menu_link_plugin_id'] = BaseFieldDefinition::create('string') + ->setLabel(t('Menu link plugin ID')) + ->setRequired(TRUE) + ->setSetting('max_length', 255); +``` + +Your module will have its own target identifier; the pattern stays the same. + +### 2. Variant selector service + +Extend `VariantSelectorBase` and implement four abstract methods. The base +class handles Thompson Sampling scoring, per-request caching, self-healing +registry registration, multilingual fallback, and page cache override. + +Here is `rl_page_title`'s selector, trimmed to the essentials: + +```php +class TitleVariantSelector extends VariantSelectorBase { + + protected function ownerModule(): string { + return 'rl_page_title'; + } + + protected function entityTypeId(): string { + return 'rl_page_title_experiment'; + } + + protected function entityClass(): string { + return PageTitleExperiment::class; + } + + protected function computeLookupHash( + string $target, + string $langcode + ): string { + return PageTitleExperiment::computeLookupHash($target, $langcode); + } + + public function selectForCurrentPage(): ?array { + return $this->selectForTarget( + $this->getCurrentInternalPath(), + $this->languageManager + ->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE) + ->getId() + ); + } + +} +``` + +`rl_menu_link` is nearly identical; only the target identifier changes +from an internal path to a menu link plugin ID. + +The return value from `selectForTarget()` is either `NULL` (no active +experiment) or an array: + +```php +[ + 'experiment_id' => 'rl_page_title-a1b2c3d4e5f6', + 'arm_id' => 'v1', + 'text' => 'The winning variant text', +] +``` + +Register the service in `*.services.yml`: + +```yaml +services: + my_module.variant_selector: + class: Drupal\my_module\Service\MyVariantSelector + arguments: + - '@entity_type.manager' + - '@rl.experiment_manager' + - '@rl.cache_manager' + - '@rl.experiment_registry' +``` + +### 3. Report decorator service + +The RL reports page (`/admin/reports/rl`) shows raw experiment and arm IDs by +default. A decorator maps those IDs back to something human-readable. + +Extend `VariantExperimentDecoratorBase` and implement: + +- `experimentIdPrefix()`: the prefix your experiment IDs start with + (e.g. `rl_page_title-`) +- `entityTypeId()`, `entityClass()`: which entity to load +- `buildExperimentDisplay()`: render array for the experiment label +- `buildOriginalArmDisplay()`: render array for the v0 (original) arm + +Tag the service with `rl_experiment_decorator`: + +```yaml +services: + my_module.decorator: + class: Drupal\my_module\Decorator\MyDecorator + arguments: ['@entity_type.manager'] + tags: + - { name: rl_experiment_decorator } +``` + +See the [Experiment decorators guide](decorators.md) for a standalone +example that does not use the base class. + +### 4. Hook integration + +This is where consumer modules diverge most. The hooks depend on what you are +testing. + +**rl_page_title** uses `hook_preprocess_page_title()` and +`hook_preprocess_html()` to swap the visible and `` tag titles: + +```php +function rl_page_title_preprocess_page_title(array &$variables) { + $result = \Drupal::service('rl_page_title.variant_selector') + ->selectForCurrentPage(); + if ($result && $result['text'] !== NULL) { + $variables['title'] = $result['text']; + } +} +``` + +**rl_menu_link** uses `hook_preprocess_menu()` to walk the menu tree and +replace link titles: + +```php +function rl_menu_link_preprocess_menu(array &$variables) { + if (empty($variables['items'])) { + return; + } + _rl_menu_link_process_items($variables['items']); +} + +function _rl_menu_link_process_items(array &$items) { + $selector = \Drupal::service('rl_menu_link.variant_selector'); + foreach ($items as &$item) { + if (isset($item['original_link']) && method_exists($item['original_link'], 'getPluginId')) { + $plugin_id = $item['original_link']->getPluginId(); + $result = $selector->selectForPluginId($plugin_id); + if ($result && $result['text'] !== NULL) { + $item['title'] = $result['text']; + } + } + if (!empty($item['below'])) { + _rl_menu_link_process_items($item['below']); + } + } +} +``` + +Both modules also use `hook_form_alter()` to add a "variant titles" vertical +tab on entity edit forms, so editors can manage experiments without visiting a +separate admin page. They attach the experiment data to the host entity's save +flow. + +Both implement `hook_entity_predelete()` to cascade-delete experiments and +purge analytics when the parent entity is removed. + +### 5. Tracking JavaScript + +Each consumer attaches a small JS behaviour that calls `Drupal.rl.turn()` and +`Drupal.rl.reward()`. The pattern varies by what counts as an impression and +what counts as a conversion: + +| Module | Turn (impression) | Reward (conversion) | +| --- | --- | --- | +| `rl_page_title` | Page load | Visitor stays 10+ seconds | +| `rl_menu_link` | Link enters viewport (IntersectionObserver) | Visitor clicks the link | +| `rl_example` | Form enters viewport | AJAX form submit (server-side `recordReward()`) | +| `rl_example_frontend` | Form enters viewport | Click event via `Drupal.rl.reward()` | + +Pass the experiment ID and arm ID to the page via `drupalSettings`: + +```php +$build['#attached']['drupalSettings']['myModule'] = [ + 'experimentId' => $result['experiment_id'], + 'armId' => $result['arm_id'], +]; +``` + +Then in your JS: + +```javascript +Drupal.behaviors.myModuleTracking = { + attach(context) { + once('my-module-tracking', '.my-element', context) + .forEach((el) => { + var config = drupalSettings.myModule; + Drupal.rl.turn(config.experimentId, config.armId); + + el.addEventListener('click', function () { + Drupal.rl.reward(config.experimentId, config.armId); + }); + }); + }, +}; +``` + +All `Drupal.rl` calls are batched (500ms window) and sent to `rl.php` in a +single request. See the [JavaScript API](../api/javascript.md) for the full +reference. + +## Without VariantSelectorBase: the direct API approach + +Not every integration needs a content entity and a selector service. For +simpler cases (a block, a form, a widget), you can use the RL services +directly. The bundled example modules demonstrate this. + +`rl_example` tests which button text gets more newsletter signups: + +```php +public function build() { + $this->experimentRegistry->register( + 'my_experiment', + 'my_module', + 'My A/B Test' + ); + + $scores = $this->experimentManager->getThompsonScores( + 'my_experiment', + NULL, + ['subscribe', 'updates', 'notify'] + ); + arsort($scores); + $best = key($scores); + $this->cacheManager->overridePageCacheIfShorter(60); + + return ['#markup' => $this->buttonTexts[$best]]; +} +``` + +The three required services: + +| Service | Purpose | +| --- | --- | +| `rl.experiment_registry` | Register your experiment so `rl.php` accepts tracking events for it | +| `rl.experiment_manager` | Get Thompson Sampling scores, record turns and rewards | +| `rl.cache_manager` | Shorten the page cache so variants rotate | + +## rl_sorting: Views integration + +[RL: A/B Test Views Content](https://www.drupal.org/project/rl_sorting) +(`rl_sorting`) is a separate project that integrates RL with Drupal Views. +It provides a Views sort plugin that reorders view results by Thompson +Sampling score, turning any View into an engagement-optimised ranking. + +The decision is server-side: the sort plugin calls `getThompsonScores()` +with the content IDs from the View results as arm IDs, then reorders the +rows before rendering. Tracking is client-side: an `IntersectionObserver` +collects visible entity IDs and sends an `action=turns` payload directly to +`rl.php` using `navigator.sendBeacon()`. Rewards fire via `sendBeacon()` +when a visitor clicks through to a listed item. + +## DXPR Builder integration + +[DXPR Builder](https://www.drupal.org/project/dxpr_builder) uses the +client-side pattern. All variants are rendered into the page HTML (so the page +can be served from Varnish or Fastly), and the builder's JS calls +`Drupal.rl.decide()` at page load to pick the winner and swap the visible +slot. This keeps the full-page cache intact while still running experiments. + +## Cache considerations + +Server-side selectors need the page cache to expire before variants can +rotate. `VariantSelectorBase` calls +`CacheManager::overridePageCacheIfShorter()` automatically. Both bundled +submodules default to 60 seconds; override `cacheTtl()` in your selector to +change it. + +Client-side integrations (DXPR Builder) are fully compatible with aggressive +page caching because the decision happens after load. + +See the [Cache management guide](cache.md) for details. + +## Putting it together: a checklist + +When building a new consumer module: + +1. **Define your experiment entity** implementing `VariantExperimentInterface` + with `VariantArmsTrait` +2. **Create a variant selector service** extending `VariantSelectorBase` +3. **Create a report decorator** extending `VariantExperimentDecoratorBase` + and tagging it with `rl_experiment_decorator` +4. **Hook into the render pipeline** to swap content with the winning variant +5. **Write tracking JS** that records turns on impression and rewards on + conversion +6. **Handle cleanup**: implement `hook_entity_predelete()` to cascade-delete + experiments when the parent entity is removed +7. **Register experiments**: the base class self-heals registration, but + explicit `register()` calls in form submit handlers ensure the registry + is in sync from the start diff --git a/docs/images/rl-2d-line-chart.jpg b/docs/images/rl-2d-line-chart.jpg new file mode 100644 index 0000000..6d13178 Binary files /dev/null and b/docs/images/rl-2d-line-chart.jpg differ diff --git a/docs/images/rl-3d-posterior-landscape.jpg b/docs/images/rl-3d-posterior-landscape.jpg new file mode 100644 index 0000000..b6a18cf Binary files /dev/null and b/docs/images/rl-3d-posterior-landscape.jpg differ diff --git a/docs/images/rl-experiment-list.jpg b/docs/images/rl-experiment-list.jpg new file mode 100644 index 0000000..b95ebcd Binary files /dev/null and b/docs/images/rl-experiment-list.jpg differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f09df70 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,86 @@ +# RL: A/B & Multivariate Testing for Drupal + +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 +fixed horizons, no third-party SaaS. + +![3D Posterior Landscape showing variant performance over time](images/rl-3d-posterior-landscape.jpg) + +## What you can A/B test + +- **[RL: A/B Test Views Content](https://www.drupal.org/project/rl_sorting)** + (`rl_sorting`): reorders any Drupal View by engagement. A Views sort plugin + calls Thompson Sampling with content IDs as arms and reorders rows before + rendering; turns and rewards are tracked client-side. +- **RL: A/B Test Page Titles** (`rl_page_title`, bundled submodule): + tests page titles for nodes, Views pages, and any controller. Hooks into the + preprocess pipeline to swap the visible title and `<title>` tag. A vertical + tab on content entity edit forms lets editors manage variants inline. +- **RL: A/B Test Menu Links** (`rl_menu_link`, bundled submodule): + tests labels in any menu link. Walks the menu tree at render time and swaps + link titles with the winning variant. A vertical tab on the menu link edit + form lets editors add alternatives. +- **DXPR Builder** integration: renders all variant slots into the page HTML + (so the page stays fully cacheable) and uses `Drupal.rl.decide()` on the + client to pick and swap the winner. +- **Custom modules**: RL provides base classes and services for building your + own consumer modules. See the + [Building a Consumer Module](guides/integrations.md) guide. + +## Features + +- **Multivariate by default**: 2 to thousands of variants, no manual configuration +- **Real-time RLHF loop**: visitor clicks update the model on every page +- **Fast HTTP REST API**: optimised JSON endpoint at `rl.php` +- **Admin reports**: per-experiment performance, traffic, and confidence at + `/admin/reports/rl` +- **Service-based architecture**: extensible decorators, custom variant selectors +- **Data sovereignty**: no cloud, no SaaS, all data stays in your Drupal database +- **GDPR-friendly tracking**: only anonymous interaction counts, no user IDs or + cookies + +## Why RL instead of fixed-horizon A/B testing? + +Traditional A/B tests run for a fixed window (say two weeks) and split traffic +50/50 the whole time, even when one variant is obviously losing. RL turns the +experiment into a feedback loop: every click adjusts the model, traffic shifts +toward the leader as soon as evidence emerges, and the test never has to "end". +You can run dozens or thousands of variants simultaneously (true multivariate +testing), and a newly added variant is in play on the next render with no +manual setup. + +## How it works + +RL uses a multi-armed bandit (Thompson Sampling). Each variant has a reward +distribution; on each render the algorithm samples from the distributions and +picks the highest sample. Wins update the distribution toward higher rewards; +losses update toward lower. Algorithm details: +[ThompsonCalculator.php](https://git.drupalcode.org/project/rl/-/blob/1.x/src/Service/ThompsonCalculator.php). + +<!-- TODO: diagram showing the Thompson Sampling feedback loop: render variant > visitor impression > conversion event > score update > next render shifted --> + + +## Use cases + +- **A/B test any content variation** without third-party SaaS +- **Multivariate test** dozens or thousands of variants at once +- **Continuous optimisation**: tests never end, the model keeps learning +- **Recommendations**: rank items by real engagement +- **Smart sorting**: reorder lists, accordions, or FAQs by visitor engagement +- **Feature flags**: route users to variants based on observed reward, not coin flip + +## Related modules + +- [RL: A/B Test Views Content](https://www.drupal.org/project/rl_sorting) (`rl_sorting`): + A/B test the order of any Drupal View +- [Analyze](https://www.drupal.org/project/analyze): content analysis and + quality scoring for Drupal +- [AI Content Strategy](https://www.drupal.org/project/ai_content_strategy): + AI-driven content strategy recommendations for Drupal + +## Resources + +- [Multi-Armed Bandit Problem](https://en.wikipedia.org/wiki/Multi-armed_bandit) (Wikipedia) +- [Thompson Sampling Paper](https://www.jstor.org/stable/2332286) (original research) +- [Finite-time Analysis](https://homes.di.unimi.it/~cesa-bianchi/Pubblicazioni/ml-02.pdf) (mathematical foundations) diff --git a/docs/rl_project_desc.html b/docs/rl_project_desc.html index 7317ef9..bdc87ef 100644 --- a/docs/rl_project_desc.html +++ b/docs/rl_project_desc.html @@ -78,7 +78,7 @@ <h3>Verify rl.php access</h3> <li><strong>Security modules</strong>: whitelist <code>/modules/contrib/rl/rl.php</code></li> </ul> -<p>If server policies prevent direct access, use the Drupal Routes API instead.</p> +<p>If server policies prevent direct PHP file access, consumer modules must implement their own tracking controller or proxy.</p> <h2>Drush command reference</h2> @@ -131,6 +131,15 @@ <h2>AI coding assistant integration</h2> <li>"What's the conversion rate for variant B?"</li> </ul> +<div class="note-tip"> + <ul> + <li><a href="https://project.pages.drupalcode.org/rl/">Module documentation</a>: experiment management, cache strategies, and decorator patterns</li> + <li><a href="https://project.pages.drupalcode.org/rl/getting-started/installation/">Installation guide</a>: composer setup, rl.php verification, and Nginx configuration for the tracking endpoint</li> + <li><a href="https://project.pages.drupalcode.org/rl/api/php/">API reference</a>: PHP, JavaScript, and HTTP APIs for recording trials, rewards, and decisions</li> + <li><a href="https://project.pages.drupalcode.org/rl/guides/integrations/">Building a consumer module</a>: create your own A/B testable components using the RL framework</li> + </ul> +</div> + <h2>API</h2> <pre><code>// Get the experiment manager diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..7916e26 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,36 @@ +site_name: 'RL: A/B & Multivariate Testing - Documentation' +site_url: 'https://project.pages.drupalcode.org/rl/' +repo_url: https://git.drupalcode.org/project/rl +repo_name: drupal/rl +edit_uri: -/edit/1.x/docs/ +exclude_docs: | + rl_project_desc.html + +theme: + name: material + features: + - navigation.footer + +plugins: + - search + - privacy: + enabled: !ENV [CI, false] + +nav: + - Home: 'index.md' + - Getting Started: + - Installation: 'getting-started/installation.md' + - Configuration: 'getting-started/configuration.md' + - API Reference: + - PHP API: 'api/php.md' + - JavaScript API: 'api/javascript.md' + - HTTP API: 'api/http.md' + - Guides: + - Building a Consumer Module: 'guides/integrations.md' + - Drush Commands: 'guides/drush.md' + - Experiment Decorators: 'guides/decorators.md' + - Cache Management: 'guides/cache.md' + - AI Assistant Integration: 'guides/ai-assistant.md' + - FAQ: 'guides/faq.md' + - Development: + - Contributing: 'development/contributing.md'