plugin = $plugin; } /* --------------------------------------------------------------------- */ /* Reads (cached) */ /* --------------------------------------------------------------------- */ /** * Every experiment on this site's project. * * 60 seconds: a merchant refreshing after starting one should see it, and * an experiment's state otherwise changes at human speed or overnight. * * @param bool $fresh Skip the cache. * @return array|\WP_Error */ public function all( $fresh = false ) { if ( $fresh ) { $this->plugin->cache()->forget( self::GROUP, 'list' ); } return $this->plugin->cache()->remember( self::GROUP, 'list', MINUTE_IN_SECONDS, function () { return $this->plugin->api()->experiments_list( $this->store_id() ); }, 30 ); } /** * One experiment, with its results and health checks. * * @param string $experiment_id Experiment id. * @param bool $fresh Skip the cache. * @return array|\WP_Error */ public function one( $experiment_id, $fresh = false ) { $key = 'detail_' . md5( (string) $experiment_id ); if ( $fresh ) { $this->plugin->cache()->forget( self::GROUP, $key ); } return $this->plugin->cache()->remember( self::GROUP, $key, MINUTE_IN_SECONDS, function () use ( $experiment_id ) { return $this->plugin->api()->experiment( $this->store_id(), $experiment_id ); }, 30 ); } /** * Finished tests and what they found. * * NOT cached. A merchant reaches this screen straight after deciding a * test, and a minute-old history that does not contain the decision they * just took is the one thing this view must never show. It is also filtered * and paged per request, so a single cache key could not describe it. * * @param array $query Optional verdict, q, page. * @return array|\WP_Error */ public function history( $query = array() ) { return $this->plugin->api()->experiment_history( $this->store_id(), $query ); } /** * What testing has been worth. Cached for a minute: it changes only when a * test is decided, and it is drawn above a list that is refetched anyway. * * @param bool $fresh Skip the cache. * @return array|\WP_Error */ public function value( $fresh = false ) { if ( $fresh ) { $this->plugin->cache()->forget( self::GROUP, 'value' ); } return $this->plugin->cache()->remember( self::GROUP, 'value', MINUTE_IN_SECONDS, function () { return $this->plugin->api()->experiment_value( $this->store_id() ); }, 30 ); } /** * Save what the merchant learned from one finished test. * * @param string $experiment_id Experiment id. * @param string $notes Their note. Empty clears it. * @return array|\WP_Error */ public function save_note( $experiment_id, $notes ) { $result = $this->plugin->api()->experiment_note( $this->store_id(), $experiment_id, $notes ); // The history is uncached, but the portfolio total is not, and a note // does not change it. Nothing to bust. return $result; } /** * Products and categories a test could run over. * * @param string|null $category Optional category. * @return array|\WP_Error */ public function subjects( $category = null, $include_facets = false ) { return $this->plugin->api()->experiment_subjects( $this->store_id(), $category, $include_facets ); } /** * Every catalogue filter the selection control offers, in one call. * * CACHED for five minutes. Facet lists move when a catalogue syncs, not * when a merchant ticks a box, and this screen sits on a merchant's own * server: re-reading them per keystroke would be latency they pay for * nothing. * * @param bool $fresh Skip the cache. * @return array|\WP_Error */ public function facets( $fresh = false ) { if ( $fresh ) { $this->plugin->cache()->forget( self::GROUP, 'facets' ); } return $this->plugin->cache()->remember( self::GROUP, 'facets', 5 * MINUTE_IN_SECONDS, function () { return $this->plugin->api()->experiment_subjects( $this->store_id(), null, true ); }, 30 ); } /** * What a selection resolves to. NEVER cached: its whole value is that it * describes the selection in front of the merchant right now. * * @param array $body { selection, type?, countOnly?, page?, pageSize? }. * @return array|\WP_Error */ public function resolve_subjects( array $body ) { $body['storeId'] = $this->store_id(); return $this->plugin->api()->experiment_resolve_subjects( $body ); } /** * A page of products for hand-picking, in the platform's own id space. * * @param string $query Optional search term. * @param int $page Zero-based page. * @return array|\WP_Error */ public function products( $query = '', $page = 0 ) { return $this->plugin->api()->experiment_products( $this->store_id(), $query, $page ); } /** * A model proposes a cohort from a sentence. Never cached: two merchants * describing different things must not share an answer, and the same * merchant asking twice means they want another go. * * @param array $body { brief, arms?, holdoutFraction? }. * @return array|\WP_Error */ public function suggest_subjects( array $body ) { $body['storeId'] = $this->store_id(); return $this->plugin->api()->experiment_suggest_subjects( $body ); } /** * Saved product selections. Cached like the facets, and busted on write. * * @param bool $fresh Skip the cache. * @return array|\WP_Error */ public function groups( $fresh = false ) { if ( $fresh ) { $this->plugin->cache()->forget( self::GROUP, 'groups' ); } return $this->plugin->cache()->remember( self::GROUP, 'groups', 5 * MINUTE_IN_SECONDS, function () { return $this->plugin->api()->product_groups( $this->store_id() ); }, 30 ); } /** * Save a selection as a named group. * * @param array $body { name, description?, selection }. * @return array|\WP_Error */ public function save_group( array $body ) { $body['storeId'] = $this->store_id(); $result = $this->plugin->api()->save_product_group( $body ); if ( ! is_wp_error( $result ) ) { $this->plugin->cache()->forget( self::GROUP, 'groups' ); } return $result; } /** * Delete a saved group. Running experiments are unaffected: their products * were frozen when they started and nothing re-resolves them. * * @param string $group_id Group id. * @return array|\WP_Error */ public function delete_group( $group_id ) { $result = $this->plugin->api()->delete_product_group( $this->store_id(), $group_id ); if ( ! is_wp_error( $result ) ) { $this->plugin->cache()->forget( self::GROUP, 'groups' ); } return $result; } /** * What the create form may offer. Not cached: it is read once per visit to * the form, and a stale answer is exactly the defect it replaces. * * @return array|\WP_Error */ public function options() { return $this->plugin->api()->experiment_options( $this->store_id() ); } /* --------------------------------------------------------------------- */ /* Writes */ /* --------------------------------------------------------------------- */ /** * The costless power check. Never cached: its whole value is that it * answers about the design in front of the merchant right now. * * @param array $body Design and product set. * @return array|\WP_Error */ public function preflight( array $body ) { $body['storeId'] = $this->store_id(); return $this->plugin->api()->experiment_preflight( $body ); } /** * Create an experiment and start the run that writes its versions. * * @param array $body Design, products, goal. * @return array|\WP_Error */ public function create( array $body ) { $body['storeId'] = $this->store_id(); $result = $this->plugin->api()->experiment_create( $body ); if ( ! is_wp_error( $result ) ) { $this->plugin->cache()->bust( self::GROUP ); } return $result; } /** * Start, pause, resume, stop, abandon or rename. * * The allowed set is checked here as well as on the platform. Not a second * opinion: it means a typo in the admin JS is refused locally rather than * spending a round trip to be told the same thing. * * @param string $experiment_id Experiment id. * @param string $action Lifecycle action. * @param array $extra Extra fields (rename carries a name). * @return array|\WP_Error */ public function act( $experiment_id, $action, array $extra = array() ) { $allowed = array( 'start', 'pause', 'resume', 'stop', 'abandon', 'rename' ); if ( ! in_array( $action, $allowed, true ) ) { return new \WP_Error( 'agencoen_bad_action', __( 'That is not something you can do to an experiment.', 'agentic-commerce-engine' ) ); } $result = $this->plugin->api()->experiment_action( $this->store_id(), $experiment_id, $action, $extra ); if ( ! is_wp_error( $result ) ) { // Both keys move: the experiment's own payload and its row in the // list. Also pull the storefront configs straight away, because // stopping a test must stop splitting visitors now and not on the // next daily tick. $this->plugin->cache()->bust( self::GROUP ); $this->plugin->ab_test()->sync_configs(); } return $result; } /** * Keep the winning version for every shopper, and finish the test. * * The arm is a hint. Rezolve Ai rebuilds the verdict from the same shaping layer * this screen rendered and refuses if it disagrees, so a merchant who left * the page open overnight cannot publish a version the evidence has since * stopped supporting. * * @param string $experiment_id Experiment id. * @param string $arm The winning arm key. * @return array|\WP_Error */ public function apply_winner( $experiment_id, $arm ) { if ( ! preg_match( '/^[a-z0-9]{1,8}$/', (string) $arm ) ) { return new \WP_Error( 'agencoen_bad_arm', __( 'That version could not be identified.', 'agentic-commerce-engine' ) ); } $result = $this->plugin->api()->experiment_apply_winner( $this->store_id(), $experiment_id, $arm ); if ( ! is_wp_error( $result ) ) { // The test is over, so the cached payload and the list row are both // wrong. Pull the storefront configs straight away as well: the // split has to stop now, not on the next daily tick. $this->plugin->cache()->bust( self::GROUP ); $this->plugin->ab_test()->sync_configs(); } return $result; } /* --------------------------------------------------------------------- */ /** * @return string */ private function store_id() { return (string) $this->plugin->settings()->get_store_id(); } }