From 0d8804a5ff80903ea7bf69bda34d7671b22eb140 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Wed, 15 Apr 2026 14:04:22 +0200 Subject: [PATCH] feat(rl.php): add batch decide action for Thompson Sampling lookups Adds a `decide` action to rl.php that resolves a batch of experiment IDs to their winning arms via the existing ThompsonScores path. Module-agnostic: callers pass `experiment_ids` and a parallel `arm_counts` list; response is `{"decisions":{eid:{armId:"vN"}, ...}}`. Lets modules like dxpr_builder's rl_dxpr_variant runtime avoid the per-request overhead of a Drupal-routed decision endpoint. The existing turn/reward hot path is unchanged and is joined by this decide branch after the same minimal kernel boot. Unknown or zero-arm experiments are omitted from the response so callers can fall back to arm 0. --- rl.php | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/rl.php b/rl.php index 2b49102..d9fef75 100644 --- a/rl.php +++ b/rl.php @@ -21,13 +21,53 @@ exit('pong'); } -if (!$action || !$experiment_id || !in_array($action, ['turn', 'turns', 'reward'])) { +// Decide action: a batch Thompson Sampling lookup that resolves +// experiment_ids to winning arms. Generic (module-agnostic) — the +// caller passes experiment_ids it already knows about and the shape +// of each experiment. Used by rl integrations that want to avoid the +// per-request overhead of a full Drupal route boot for decision +// fetches. Schema: +// experiment_ids comma-separated list of pre-registered IDs +// arm_counts comma-separated parallel list of integers +// Response: +// {"decisions":{"":{"armId":"vN"}, ...}} +// Unknown / unregistered / zero-arm experiments are returned as NULL +// so the caller can fall back to the first arm. +if ($action === 'decide') { + $ids_raw = filter_input(INPUT_POST, 'experiment_ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + $counts_raw = filter_input(INPUT_POST, 'arm_counts', FILTER_SANITIZE_FULL_SPECIAL_CHARS); + if (!$ids_raw || !$counts_raw) { + http_response_code(400); + header('Content-Type: application/json'); + exit('{"decisions":{}}'); + } + $ids = array_map('trim', explode(',', $ids_raw)); + $counts = array_map('intval', explode(',', $counts_raw)); + if (count($ids) !== count($counts)) { + http_response_code(400); + header('Content-Type: application/json'); + exit('{"decisions":{}}'); + } + $pairs = []; + foreach ($ids as $i => $eid) { + if ($eid === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $eid)) { + continue; + } + $count = $counts[$i] ?? 0; + if ($count < 2) { + continue; + } + $pairs[$eid] = $count; + } + // Fall through to Drupal kernel bootstrap below, then branch on + // $action === 'decide' after the container is available. +} +elseif (!$action || !$experiment_id || !in_array($action, ['turn', 'turns', 'reward'])) { http_response_code(400); exit('Invalid request parameters'); } - -// Validate experiment ID format (alphanumeric, hyphens, underscores). -if (!preg_match('/^[a-zA-Z0-9_-]+$/', $experiment_id)) { +elseif (!preg_match('/^[a-zA-Z0-9_-]+$/', $experiment_id)) { + // Validate experiment ID format (alphanumeric, hyphens, underscores). http_response_code(400); exit('Invalid experiment_id format'); } @@ -63,6 +103,49 @@ $container = $kernel->getContainer(); $registry = $container->get('rl.experiment_registry'); + + // Decide action: batch Thompson Sampling lookup, returns JSON. + // Skips the single-experiment registration check above since this + // action accepts a list and per-id registration is verified inline. + if ($action === 'decide') { + $manager = $container->has('rl.experiment_manager') + ? $container->get('rl.experiment_manager') + : NULL; + if ($manager === NULL) { + http_response_code(503); + header('Content-Type: application/json'); + exit('{"decisions":{},"error":"rl.experiment_manager not available"}'); + } + $decisions = new stdClass(); + foreach ($pairs as $eid => $arm_count) { + if (!$registry->isRegistered($eid)) { + // Unknown experiment — caller will fall back to arm 0. + continue; + } + $arm_ids = []; + for ($i = 0; $i < $arm_count; $i++) { + $arm_ids[] = 'v' . $i; + } + try { + $scores = $manager->getThompsonScores($eid, NULL, $arm_ids); + } + catch (\Throwable $e) { + continue; + } + if (!is_array($scores) || !$scores) { + continue; + } + arsort($scores); + $winner = (string) key($scores); + $decisions->$eid = ['armId' => $winner]; + } + http_response_code(200); + header('Content-Type: application/json'); + header('Cache-Control: no-store, private, max-age=0'); + echo json_encode(['decisions' => $decisions]); + exit(); + } + if (!$registry->isRegistered($experiment_id)) { exit(); }