$feature, 'label' => $meta['label'], 'risk' => $meta['risk'], 'status' => $allowed ? 'eligible' : 'blocked', 'reason' => $allowed ? 'Policy allows a controlled experiment.' : 'Policy or conflict engine blocks this feature.', ); } return $plan; } private static function current_state() { $state = array(); foreach ( self::KEYS as $key ) { $value = get_option( $key, null ); if ( null !== $value ) { $state[ $key ] = $value; } } return $state; } public static function snapshot() { $snapshot = self::current_state(); $snapshot['created_at'] = time(); update_option( self::OPTION_STATE, $snapshot, false ); return $snapshot; } /** * What changed between two states (pure, tested): option => true when * the whole option was created, deleted or replaced, or the list of the * array keys that changed. */ public static function changes_between( array $before, array $after ) { $out = array(); foreach ( self::KEYS as $opt ) { $had = array_key_exists( $opt, $before ); $has = array_key_exists( $opt, $after ); if ( ! $had && ! $has ) { continue; } if ( $had && $has && is_array( $before[ $opt ] ) && is_array( $after[ $opt ] ) ) { $keys = array(); foreach ( array_keys( $before[ $opt ] + $after[ $opt ] ) as $k ) { if ( ! array_key_exists( $k, $before[ $opt ] ) || ! array_key_exists( $k, $after[ $opt ] ) || $before[ $opt ][ $k ] !== $after[ $opt ][ $k ] ) { $keys[] = $k; } } if ( $keys ) { $out[ $opt ] = $keys; } } elseif ( $had !== $has || $before[ $opt ] !== $after[ $opt ] ) { $out[ $opt ] = true; } } return $out; } /** * Restores the snapshot values of the changed keys only ($changed from * changes_between(); by default what differs now), so a setting changed * by someone else in the meantime is not overwritten. Options are written * without an autoload argument: their autoload flag is left as it is. */ public static function restore( $snapshot = null, $changed = null ) { $snapshot = is_array( $snapshot ) ? $snapshot : get_option( self::OPTION_STATE, array() ); if ( empty( $snapshot ) ) { return false; } unset( $snapshot['created_at'] ); $current = self::current_state(); $changed = is_array( $changed ) ? $changed : self::changes_between( $snapshot, $current ); foreach ( $changed as $key => $keys ) { if ( ! in_array( $key, self::KEYS, true ) ) { continue; } $had = array_key_exists( $key, $snapshot ); if ( true === $keys || ! $had || ! is_array( $snapshot[ $key ] ) || ! isset( $current[ $key ] ) || ! is_array( $current[ $key ] ) ) { if ( $had ) { update_option( $key, $snapshot[ $key ] ); } elseif ( function_exists( 'delete_option' ) ) { // A change may create an option that did not exist before the // experiment. Delete it so rollback restores the exact state. delete_option( $key ); } continue; } $value = $current[ $key ]; foreach ( (array) $keys as $k ) { if ( array_key_exists( $k, $snapshot[ $key ] ) ) { $value[ $k ] = $snapshot[ $key ][ $k ]; } else { unset( $value[ $k ] ); } } update_option( $key, $value ); } return true; } public static function evaluate( array $before, array $after, $tolerance = 0.08 ) { if ( empty( $before['ok'] ) || empty( $after['ok'] ) || empty( $before['metrics'] ) || empty( $after['metrics'] ) || ! is_array( $before['metrics'] ) || ! is_array( $after['metrics'] ) ) { return array( 'status' => 'rollback', 'reason' => __( 'The measurement failed before or after the change.', 'celero-adaptive-speed' ), 'score' => null ); } $b = $before['metrics']; $a = $after['metrics']; $penalties = array(); foreach ( array( 'request_ms', 'html_bytes', 'script_count', 'stylesheet_count', 'image_count' ) as $key ) { if ( isset( $b[ $key ], $a[ $key ] ) && (float) $b[ $key ] > 0 ) { $delta = ( (float) $a[ $key ] - (float) $b[ $key ] ) / (float) $b[ $key ]; // Server time is noisy: require a relative AND an absolute worsening. $limit = 'request_ms' === $key ? max( $tolerance, 0.15 ) : $tolerance; if ( 'request_ms' === $key && ( (float) $a[ $key ] - (float) $b[ $key ] ) < 25 ) { continue; } // Paired measurement: slower only if B lost most rounds (≥ 70%), // not because of one busy moment of the server. if ( 'request_ms' === $key && ! empty( $a['paired_rounds'] ) && (int) $a['paired_slower'] < ceil( 0.7 * (int) $a['paired_rounds'] ) ) { continue; } if ( $delta > $limit ) { $penalties[ $key ] = round( $delta * 100, 2 ); } } } if ( ! empty( $penalties ) ) { return array( 'status' => 'rollback', 'reason' => self::describe( $penalties, $b, $a ), 'regressions' => $penalties, 'score' => 0 ); } $improvements = 0; foreach ( array( 'request_ms', 'html_bytes', 'script_count', 'stylesheet_count' ) as $key ) { if ( isset( $b[ $key ], $a[ $key ] ) && (float) $a[ $key ] < (float) $b[ $key ] ) { $improvements++; } } return array( 'status' => 'keep', 'reason' => $improvements ? __( 'Nothing got worse; some measurements improved.', 'celero-adaptive-speed' ) : __( 'Nothing got worse.', 'celero-adaptive-speed' ), 'improvements' => $improvements, 'score' => $improvements ); } /** Plain-language reason for a rollback (what got worse, before → after). */ public static function describe( array $penalties, array $b, array $a ) { $out = array(); foreach ( $penalties as $key => $pct ) { $from = (float) $b[ $key ]; $to = (float) $a[ $key ]; switch ( $key ) { case 'request_ms': /* translators: 1: before, 2: after, 3: percent */ $out[] = sprintf( __( 'The server answered more slowly: %1$d → %2$d ms (+%3$d%%).', 'celero-adaptive-speed' ), round( $from ), round( $to ), round( $pct ) ); break; case 'html_bytes': /* translators: 1: before, 2: after, 3: percent */ $out[] = sprintf( __( 'The page got heavier: %1$s → %2$s KB (+%3$d%%).', 'celero-adaptive-speed' ), round( $from / 1024, 1 ), round( $to / 1024, 1 ), round( $pct ) ); break; case 'script_count': /* translators: 1: before, 2: after */ $out[] = sprintf( __( 'More script files: %1$d → %2$d.', 'celero-adaptive-speed' ), $from, $to ); break; case 'stylesheet_count': /* translators: 1: before, 2: after */ $out[] = sprintf( __( 'More style files: %1$d → %2$d.', 'celero-adaptive-speed' ), $from, $to ); break; case 'image_count': /* translators: 1: before, 2: after */ $out[] = sprintf( __( 'More images: %1$d → %2$d.', 'celero-adaptive-speed' ), $from, $to ); break; } } return implode( ' ', $out ); } public static function record( $feature, array $evaluation ) { $history = get_option( self::OPTION_HISTORY, array() ); if ( ! is_array( $history ) ) { $history = array(); } array_unshift( $history, array( 'feature' => sanitize_key( $feature ), 'evaluation' => $evaluation, 'timestamp' => time(), ) ); $history = array_slice( $history, 0, 20 ); update_option( self::OPTION_HISTORY, $history, false ); return $history[0]; } public static function run_controlled( $feature, callable $change ) { if ( class_exists( 'CTPS_Safety' ) && CTPS_Safety::is_disabled() ) { return array( 'ok' => false, 'status' => 'blocked', 'feature' => $feature, 'message' => __( 'Emergency mode is on: Adaptive Speed is disabled.', 'celero-adaptive-speed' ) ); } if ( ! WPADAPT_Policy::can( $feature ) ) { return array( 'ok' => false, 'status' => 'blocked', 'feature' => $feature, 'message' => 'Policy blocked this feature.' ); } $before = WPADAPT_Benchmark::run_median(); if ( empty( $before['ok'] ) ) { return array( 'ok' => false, 'status' => 'blocked', 'feature' => $feature, 'message' => 'Baseline benchmark failed.' ); } $snapshot = self::snapshot(); try { $result = call_user_func( $change ); if ( false === $result ) { self::restore( $snapshot ); return array( 'ok' => false, 'status' => 'rollback', 'feature' => $feature, 'message' => 'Optimization action failed; settings were restored.' ); } // Keys written by the action, taken now: a later edit made while // the benchmark runs is not part of the experiment. $changed = self::changes_between( $snapshot, self::current_state() ); $after = WPADAPT_Benchmark::run_median(); $evaluation = self::evaluate( $before, $after ); if ( 'rollback' === $evaluation['status'] ) { self::restore( $snapshot, $changed ); } self::record( $feature, $evaluation ); return array( 'ok' => true, 'status' => $evaluation['status'], 'feature' => $feature, 'before' => $before, 'after' => $after, 'evaluation' => $evaluation ); } catch ( \Throwable $e ) { self::restore( $snapshot ); $evaluation = array( 'status' => 'rollback', 'reason' => 'An exception interrupted the experiment; settings were restored.', 'score' => null ); self::record( $feature, $evaluation ); return array( 'ok' => false, 'status' => 'rollback', 'feature' => $feature, 'message' => $evaluation['reason'] ); } } public static function report() { return array( 'plan' => self::plan(), 'history' => get_option( self::OPTION_HISTORY, array() ), ); } }