PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 All 27 releases
xspeed / includes / class-optimizer.php

class-optimizer.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-optimizer.php

305 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optimizer — the autopilot loop.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Apply an optimization plan one step at a time, verifying after each.
14 *
15 * The loop is deliberately boring, because the interesting part is what it
16 * refuses to do:
17 *
18 * - **One change at a time.** A batch that fails tells you nothing about
19 * which setting did it, and leaves you reverting work that was fine.
20 * - **Purge before verifying.** No settings change purges the page cache on
21 * its own (#205), so a check run against an unpurged site verifies the page
22 * as it was BEFORE the change and passes anything.
23 * - **Revert only the failing step.** A break at step 9 must not discard the
24 * eight verified wins before it, and must not abort the four steps after.
25 * - **Never leave a step applied-but-unverified.** If sampling itself fails,
26 * that is not permission to assume success.
27 *
28 * Every side effect is injected rather than called directly, so the whole loop
29 * is unit-testable without a WordPress install or a live site: the tests drive
30 * it with an applier that records, and a sampler that breaks on cue.
31 *
32 * @since 1.2.0
33 */
34 final class Optimizer {
35
36 /**
37 * Timing samples per state (before / after), medians compared.
38 *
39 * One sample cannot drive a decision: on the site that motivated this,
40 * render time across runs with identical config spanned more than 2×.
41 * Three is the floor at which a median starts to mean something without
42 * making each step cost a page-load storm.
43 */
44 public const PERF_SAMPLES = 3;
45
46 /**
47 * A regression must clear BOTH of these to trigger a revert — a
48 * relative floor so slow sites aren't reverted over jitter that is
49 * large in ms but small in proportion, and an absolute floor so fast
50 * sites aren't reverted over a 20ms wobble that is huge in percent.
51 * Anything inside the floor reports `unchanged`, never `worse`:
52 * confidently reverting on noise is a worse failure than keeping a
53 * mild regression, because it is invisible and self-assured.
54 */
55 public const PERF_REGRESSION_PCT = 30.0;
56 public const PERF_REGRESSION_MIN_MS = 150.0;
57
58 /**
59 * Run a plan.
60 *
61 * @param array<int,array<string,mixed>> $steps Ordered steps from Optimize_Plan::build().
62 * @param array<string,mixed> $baseline Sample taken before anything changed.
63 * @param array{
64 * apply:callable, // (array $step): ?string — a non-empty string refuses the step
65 * revert:callable,
66 * purge:callable,
67 * sample:callable,
68 * time?:callable,
69 * now?:callable
70 * } $io Injected side effects.
71 * @param int $budget_seconds Wall-clock cap; 0 = uncapped.
72 * @return array{applied:array<int,array<string,mixed>>,reverted:array<int,array<string,mixed>>,skipped:array<int,array<string,string>>}
73 */
74 public static function run( array $steps, array $baseline, array $io, int $budget_seconds = 0 ): array {
75 $apply = $io['apply'];
76 $revert = $io['revert'];
77 $purge = $io['purge'];
78 $sample = $io['sample'];
79 // Optional timing probe: returns one render time in ms, or null.
80 // Kept separate from `sample` so the integrity contract (exactly
81 // one sample per step) is untouched, and a caller without a probe
82 // gets the old behavior with every verdict `unknown`.
83 $time = isset( $io['time'] ) && is_callable( $io['time'] ) ? $io['time'] : null;
84 $now = $io['now'] ?? static function () {
85 return time();
86 };
87
88 $started = (int) $now();
89 $applied = array();
90 $reverted = array();
91 $skipped = array();
92
93 foreach ( $steps as $i => $step ) {
94 // Budget check BEFORE applying, never mid-step: stopping between
95 // "wrote the setting" and "verified it" is the one state this
96 // loop must never end in.
97 if ( $budget_seconds > 0 && ( (int) $now() - $started ) >= $budget_seconds ) {
98 foreach ( array_slice( $steps, $i ) as $rest ) {
99 $skipped[] = array(
100 'id' => (string) $rest['id'],
101 'why' => __( 'Ran out of time before this step.', 'xspeed' ),
102 );
103 }
104 break;
105 }
106
107 $before = self::snapshot_of( $step );
108
109 // Pre-change timing baseline for THIS step. Taken fresh each
110 // step rather than reused from the last one, because the
111 // previous step just changed the site.
112 $pre_perf = null !== $time ? self::collect_times( $time, self::PERF_SAMPLES ) : null;
113
114 /*
115 * `apply` may refuse. Page caching is the one step whose write is
116 * gated on shared state another plugin can own — Cache::toggle()
117 * returns a reason and changes nothing. Reporting that step as
118 * applied-and-verified told the user the opposite of what
119 * happened: the run claimed "Turn on page caching · verified"
120 * over a site whose cache was still off.
121 *
122 * A refusal is a skip, not a revert: nothing was written, so
123 * there is nothing to undo, nothing to purge, and no sample to
124 * spend.
125 */
126 $refusal = $apply( $step );
127 if ( is_string( $refusal ) && '' !== $refusal ) {
128 $skipped[] = array(
129 'id' => (string) $step['id'],
130 'why' => $refusal,
131 );
132 continue;
133 }
134 $purge();
135
136 $current = $sample();
137
138 // A sample we could not take is NOT a pass. Treating an
139 // unreachable site as "probably fine" is how an autopilot leaves
140 // a site broken and reports success.
141 if ( ! is_array( $current ) ) {
142 $revert( $step, $before );
143 $purge();
144 $reverted[] = array(
145 'id' => (string) $step['id'],
146 'why' => __( 'Could not load the page to check it, so the change was undone.', 'xspeed' ),
147 );
148 continue;
149 }
150
151 $check = Optimize_Verifier::compare( $baseline, $current );
152 if ( ! $check['ok'] ) {
153 $revert( $step, $before );
154 $purge();
155 $reverted[] = array(
156 'id' => (string) $step['id'],
157 'why' => implode( ' ', $check['failures'] ),
158 );
159 continue;
160 }
161
162 // Integrity holds — now ask whether the change HELPED. "Verified"
163 // used to stop at "the HTML still renders", which kept a change
164 // that made the site measurably slower and reported it as a win.
165 // (#310 — defer_js regressed TBT on every measured run and was
166 // marked verified.)
167 $post_perf = null !== $time ? self::collect_times( $time, self::PERF_SAMPLES ) : null;
168 $measurement = self::measure( $pre_perf, $post_perf );
169
170 if ( 'worse' === $measurement['improved'] ) {
171 $revert( $step, $before );
172 $purge();
173 $reverted[] = array(
174 'id' => (string) $step['id'],
175 'why' => sprintf(
176 /* translators: 1: median render time before, 2: after, 3: percent change. */
177 __( 'The page got measurably slower: median render time %1$dms before, %2$dms after (+%3$d%%), beyond what run-to-run noise explains. The change was undone.', 'xspeed' ),
178 (int) $measurement['before_ms'],
179 (int) $measurement['after_ms'],
180 (int) $measurement['change_pct']
181 ),
182 'measurement' => $measurement,
183 );
184 continue;
185 }
186
187 $applied[] = array(
188 'id' => (string) $step['id'],
189 'change' => (string) $step['label'],
190 // Two separate claims where there used to be one. `renders`
191 // is the integrity check (what `verified` really meant);
192 // `improved` is the measured performance verdict —
193 // better / unchanged / unknown here, since `worse` was
194 // reverted above. Neither implies the other.
195 'renders' => true,
196 'improved' => $measurement['improved'],
197 'measurement' => $measurement,
198 // Kept for consumers reading the old field; means renders.
199 'verified' => true,
200 );
201 }
202
203 return array(
204 'applied' => $applied,
205 'reverted' => $reverted,
206 'skipped' => $skipped,
207 );
208 }
209
210 /**
211 * Run the timing probe N times and reduce to a median + spread.
212 *
213 * @param callable $time Probe returning one render time in ms, or null.
214 * @return array{median_ms:float,spread_ms:float,samples:int}|null Null when no probe run returned a number.
215 */
216 private static function collect_times( callable $time, int $n ): ?array {
217 $times = array();
218 for ( $i = 0; $i < $n; $i++ ) {
219 $t = $time();
220 if ( is_numeric( $t ) && (float) $t > 0 ) {
221 $times[] = (float) $t;
222 }
223 }
224 if ( array() === $times ) {
225 return null;
226 }
227 sort( $times );
228 $count = count( $times );
229 $middle = (int) floor( $count / 2 );
230 $median = ( 0 === $count % 2 )
231 ? ( $times[ $middle - 1 ] + $times[ $middle ] ) / 2
232 : $times[ $middle ];
233
234 return array(
235 'median_ms' => $median,
236 'spread_ms' => $times[ $count - 1 ] - $times[0],
237 'samples' => $count,
238 );
239 }
240
241 /**
242 * The performance verdict for one step.
243 *
244 * `worse` only when the after-median regresses past BOTH noise floors;
245 * symmetric rule for `better`; inside the floor is `unchanged`; and a
246 * state we could not time is `unknown` — never a guess in either
247 * direction. The raw medians and spreads ride along so the verdict is
248 * auditable rather than an oracle.
249 *
250 * @param array{median_ms:float,spread_ms:float,samples:int}|null $pre Before the change.
251 * @param array{median_ms:float,spread_ms:float,samples:int}|null $post After it.
252 * @return array{improved:string,before_ms:float|null,after_ms:float|null,before_spread_ms:float|null,after_spread_ms:float|null,change_pct:float|null,metric:string}
253 */
254 private static function measure( ?array $pre, ?array $post ): array {
255 $out = array(
256 'metric' => 'render_time',
257 'improved' => 'unknown',
258 'before_ms' => null !== $pre ? round( $pre['median_ms'] ) : null,
259 'after_ms' => null !== $post ? round( $post['median_ms'] ) : null,
260 'before_spread_ms' => null !== $pre ? round( $pre['spread_ms'] ) : null,
261 'after_spread_ms' => null !== $post ? round( $post['spread_ms'] ) : null,
262 'change_pct' => null,
263 );
264 if ( null === $pre || null === $post || $pre['median_ms'] <= 0 ) {
265 return $out;
266 }
267
268 $delta = $post['median_ms'] - $pre['median_ms'];
269 $pct = 100.0 * $delta / $pre['median_ms'];
270 $out['change_pct'] = round( $pct, 1 );
271
272 $past_floor = abs( $delta ) >= self::PERF_REGRESSION_MIN_MS
273 && abs( $pct ) >= self::PERF_REGRESSION_PCT;
274
275 if ( ! $past_floor ) {
276 $out['improved'] = 'unchanged';
277 } elseif ( $delta > 0 ) {
278 $out['improved'] = 'worse';
279 } else {
280 $out['improved'] = 'better';
281 }
282
283 return $out;
284 }
285
286 /**
287 * The values to put back if this step has to be undone.
288 *
289 * Every step in the catalog turns something ON, so the inverse is the
290 * same keys set to false. Kept as its own method so a future step whose
291 * inverse is not simply `false` has one obvious place to say so, rather
292 * than the revert path quietly writing the wrong thing.
293 *
294 * @param array<string,mixed> $step Step definition.
295 * @return array<string,mixed>
296 */
297 private static function snapshot_of( array $step ): array {
298 $out = array();
299 foreach ( (array) $step['values'] as $key => $value ) {
300 $out[ $key ] = is_bool( $value ) ? ! $value : false;
301 }
302 return $out;
303 }
304 }
305