PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-optimizer.php

class-optimizer.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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