PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / diagnostics / CanaryLadderInterpretation.php

CanaryLadderInterpretation.php in 404 Solution trunk, at includes/diagnostics/CanaryLadderInterpretation.php

275 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Pure causal interpretation policy for completed canary ladder observations.
9 *
10 * This class reads no journals and emits no response. Callers supply browser
11 * observations and the already-joined server delivery evidence.
12 */
13 final class ABJ_404_Solution_CanaryLadderInterpretation {
14
15 /** The stream step's flush was observed crossing the SAPI boundary. */
16 const STREAM_FLUSH_EVIDENCE_REACHED = 'reached_sapi';
17
18 /** The stream step ran and its flush provably reached nobody. */
19 const STREAM_FLUSH_EVIDENCE_NOT_REACHED = 'did_not_reach_sapi';
20
21 /** No journaled flush outcome exists for the stream step. */
22 const STREAM_FLUSH_EVIDENCE_UNKNOWN = 'unknown';
23
24 /** The same-phase control ran and failed alongside the real request. */
25 const CONTROL_EVIDENCE_FAILED = 'failed';
26
27 /** The same-phase control ran and did not fail: claims below are vetted. */
28 const CONTROL_EVIDENCE_DID_NOT_FAIL = 'did_not_fail';
29
30 /**
31 * The control produced no usable overlap, so whether it failed is unknown.
32 *
33 * Published beside the older `samePhaseControlFailed` boolean rather than
34 * replacing it: that field is already in support payloads, and a reader
35 * that knows only the boolean must keep working. The boolean cannot
36 * express this third state, which is exactly how "we never found out"
37 * came to be read as "it did not fail".
38 */
39 const CONTROL_EVIDENCE_UNKNOWN = 'unknown';
40
41 /** Maximum hostile step-name length accepted by the rewrite verdict. */
42 private const MAX_REPORTED_STEP_CHARS = 32;
43
44 /**
45 * The steps this class reads, which is NOT CanaryLadderStep::DISPATCHED.
46 *
47 * static_asset is a browser-only fetch with no server dispatch, so it is
48 * absent from DISPATCHED while being the very first thing interpreted;
49 * reusing that list would silently drop the step whose absence the
50 * observedSteps field exists to reveal.
51 */
52 private const INTERPRETED_STEPS = array(
53 ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET,
54 ABJ_404_Solution_CanaryLadderStep::CONCURRENT_CONTROL,
55 ABJ_404_Solution_CanaryLadderStep::AUTH_ONLY,
56 ABJ_404_Solution_CanaryLadderStep::POST_LIMITER,
57 ABJ_404_Solution_CanaryLadderStep::SUMMARY,
58 ABJ_404_Solution_CanaryLadderStep::INERT,
59 ABJ_404_Solution_CanaryLadderStep::COMPRESS_ON,
60 ABJ_404_Solution_CanaryLadderStep::COMPRESS_OFF,
61 ABJ_404_Solution_CanaryLadderStep::STREAM,
62 );
63
64 /**
65 * The interpretation matrix: which single cause space the ladder's
66 * outcomes point to. Pure and side-effect free so the comparison logic
67 * can be tested directly; the AJAX orchestration journals the result.
68 *
69 * @param array<string, mixed> $observations Client-reported outcomes keyed by step ID.
70 * @param bool $realRequestFailed Whether the real request that armed the ladder failed.
71 * @param array<string, mixed> $deliveryEvidence Server-resolved emitted/delivered evidence.
72 * @return array<string, mixed>
73 */
74 public static function interpret(
75 array $observations,
76 bool $realRequestFailed = true,
77 array $deliveryEvidence = array()
78 ): array {
79 $entry = static function (array $obs, string $step): array {
80 $found = $obs[$step] ?? null;
81 return is_array($found) ? $found : array();
82 };
83 // Whether the browser reported this step AT ALL, which is a different
84 // question from whether it succeeded. Every `ok` test on an unreported
85 // step answers false, so without this the ladder cannot tell "the step
86 // failed" from "the step never ran" -- and it published the first
87 // answer for the second, manufacturing a causal claim about a user's
88 // site out of an empty observation map. Each verdict below therefore
89 // requires positive evidence that the steps it reasons about were
90 // actually measured.
91 $observed = static function (array $obs, string $step): bool {
92 return is_array($obs[$step] ?? null);
93 };
94 $ok = static function (array $found): bool {
95 return !empty($found['ok']);
96 };
97
98 $staticAsset = $entry($observations, ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET);
99 $authOnly = $entry($observations, ABJ_404_Solution_CanaryLadderStep::AUTH_ONLY);
100 $postLimiter = $entry($observations, ABJ_404_Solution_CanaryLadderStep::POST_LIMITER);
101 $summary = $entry($observations, ABJ_404_Solution_CanaryLadderStep::SUMMARY);
102 $inert = $entry($observations, ABJ_404_Solution_CanaryLadderStep::INERT);
103 $compressOn = $entry($observations, ABJ_404_Solution_CanaryLadderStep::COMPRESS_ON);
104 $compressOff = $entry($observations, ABJ_404_Solution_CanaryLadderStep::COMPRESS_OFF);
105 $stream = $entry($observations, ABJ_404_Solution_CanaryLadderStep::STREAM);
106 $streamGapMs = isset($stream['gapMs']) && is_numeric($stream['gapMs']) ? (int)$stream['gapMs'] : 0;
107 $concurrent = $entry($observations, ABJ_404_Solution_CanaryLadderStep::CONCURRENT_CONTROL);
108 $controlEvidence = self::samePhaseControlEvidence($concurrent, $realRequestFailed);
109 $samePhaseControlFailed = $controlEvidence === self::CONTROL_EVIDENCE_FAILED;
110 $rawComparisons = $deliveryEvidence['comparisons'] ?? null;
111 $bodySizes = ABJ_404_Solution_ResponseBodyRewriteVerdict::fromComparisons(
112 is_array($rawComparisons) ? $rawComparisons : array(),
113 self::MAX_REPORTED_STEP_CHARS
114 );
115 $streamFlush = self::streamFlushEvidence($deliveryEvidence);
116
117 return array_merge(array(
118 'browserOrNetworkCausal' => $observed($observations, ABJ_404_Solution_CanaryLadderStep::STATIC_ASSET)
119 && !$ok($staticAsset),
120 'bootAuthOrDeliveryCausal' => $ok($staticAsset)
121 && $observed($observations, ABJ_404_Solution_CanaryLadderStep::AUTH_ONLY)
122 && !$ok($authOnly),
123 'limiterCausal' => $ok($authOnly)
124 && $observed($observations, ABJ_404_Solution_CanaryLadderStep::POST_LIMITER)
125 && !$ok($postLimiter),
126 // All server work completes but an inert response of the same
127 // size fails: the failure tracks size or delivery, not query cost.
128 // A conclusively failed control vetoes these two: if the control
129 // failed in the same phase, everything was failing and neither
130 // finding is about size or content. An UNKNOWN control does not
131 // veto, deliberately -- withholding the claim would make `false`
132 // mean either "not this cause" or "could not tell", which is the
133 // same two-meanings-in-one-value defect this file is fixing
134 // elsewhere. The claim stands and `samePhaseControlEvidence` says
135 // whether anything vetted it.
136 'sizeOrDeliveryCausal' => !$samePhaseControlFailed && $ok($summary)
137 && $observed($observations, ABJ_404_Solution_CanaryLadderStep::INERT)
138 && !$ok($inert),
139 // A same-size inert filler succeeds while the real request fails:
140 // the failure tracks content inspection rather than size alone.
141 'contentInspectionCausal' => !$samePhaseControlFailed && $ok($inert) && $realRequestFailed,
142 'samePhaseControlFailed' => $samePhaseControlFailed,
143 'samePhaseControlEvidence' => $controlEvidence,
144 'compressionCausal' => $ok($compressOff)
145 && $observed($observations, ABJ_404_Solution_CanaryLadderStep::COMPRESS_ON)
146 && !$ok($compressOn),
147 // Positive evidence that the flush crossed the SAPI is required.
148 // Unknown cannot mean streamed: a foreign buffer can absorb the
149 // flush while the request still reports a long browser-side gap.
150 'streamingBufferCausal' => !$ok($stream) && $streamGapMs > 2000
151 && $streamFlush === self::STREAM_FLUSH_EVIDENCE_REACHED,
152 'streamFlushEvidence' => $streamFlush,
153 // Which steps actually reported. Without this a `false` causal flag
154 // means either "this step ran and cleared the cause" or "this step
155 // never ran", and a reader cannot tell a healthy ladder from a
156 // ladder that barely executed. Published as a list so it stays
157 // additive for readers that do not know the field.
158 'observedSteps' => self::observedSteps($observations),
159 ), $bodySizes, self::baselineTrend($observations));
160 }
161
162 /**
163 * Name flush evidence as a tri-state instead of collapsing unknown to a
164 * boolean that would silently claim a measurement was made.
165 *
166 * @param array<string, mixed> $deliveryEvidence
167 */
168 private static function streamFlushEvidence(array $deliveryEvidence): string {
169 $reached = $deliveryEvidence['stream_flush_reached_sapi'] ?? null;
170 if (!is_bool($reached)) {
171 return self::STREAM_FLUSH_EVIDENCE_UNKNOWN;
172 }
173 return $reached
174 ? self::STREAM_FLUSH_EVIDENCE_REACHED : self::STREAM_FLUSH_EVIDENCE_NOT_REACHED;
175 }
176
177 /**
178 * The ladder steps the browser actually reported, in a stable order.
179 *
180 * Reads the same key names the verdicts above read, so a step that stops
181 * being interpreted cannot keep appearing here as though it were.
182 *
183 * @param array<string, mixed> $observations
184 * @return array<int, string>
185 */
186 private static function observedSteps(array $observations): array {
187 $reported = array();
188 foreach (self::INTERPRETED_STEPS as $step) {
189 if (is_array($observations[$step] ?? null)) {
190 $reported[] = $step;
191 }
192 }
193 return $reported;
194 }
195
196 /**
197 * Whether the same-phase control failed, did not fail, or never said.
198 *
199 * This used to answer a bare bool, and its docblock claimed missing or
200 * malformed evidence "remains unknown". The consumption is what made that
201 * false: an unknown control returned false, the callers read
202 * `!$samePhaseControlFailed` as permission, and a claim nothing had vetted
203 * shipped indistinguishable from one a healthy control had cleared.
204 * Absence of a veto is not permission.
205 *
206 * Both non-unknown answers require the SAME positive evidence -- a computed
207 * overlap with a real duration -- because that overlap is what makes the
208 * control same-phase at all. Without it there is no control, only a
209 * request that happened nearby.
210 *
211 * @param array<string, mixed> $concurrent
212 * @return self::CONTROL_EVIDENCE_*
213 */
214 private static function samePhaseControlEvidence(
215 array $concurrent,
216 bool $realRequestFailed
217 ): string {
218 $receipt = is_array($concurrent['receipt'] ?? null) ? $concurrent['receipt'] : array();
219 $overlap = is_array($concurrent['overlap'] ?? null) ? $concurrent['overlap'] : array();
220 $tableOutcome = is_scalar($concurrent['tableOutcome'] ?? null)
221 ? (string)$concurrent['tableOutcome'] : '';
222 $overlapState = is_scalar($overlap['state'] ?? null) ? (string)$overlap['state'] : '';
223
224 $overlapMeasured = $overlapState === ABJ_404_Solution_ConcurrentControlReceipt::OVERLAP_COMPUTED
225 && ABJ_404_Solution_ExactInteger::readOr($overlap['durationMs'] ?? null, 1, 0) > 0;
226 if (!$overlapMeasured) {
227 return self::CONTROL_EVIDENCE_UNKNOWN;
228 }
229 if ($realRequestFailed && $tableOutcome !== 'success' && empty($receipt['ok'])) {
230 return self::CONTROL_EVIDENCE_FAILED;
231 }
232 return self::CONTROL_EVIDENCE_DID_NOT_FAIL;
233 }
234
235 /**
236 * Summarize repeated fixed-size controls in chronological order. A
237 * changing control is drift, never a step effect.
238 *
239 * @param array<string, mixed> $observations
240 * @return array<string, int>
241 */
242 private static function baselineTrend(array $observations): array {
243 $raw = $observations[ABJ_404_Solution_CanaryLadderStep::BASELINE_CONTROL] ?? array();
244 $baselines = is_array($raw) ? $raw : array();
245 $count = 0;
246 $okCount = 0;
247 $firstMs = null;
248 $lastMs = null;
249 foreach ($baselines as $baseline) {
250 if (!is_array($baseline)) {
251 continue;
252 }
253 $count++;
254 if (!empty($baseline['ok'])) {
255 $okCount++;
256 }
257 if (isset($baseline['ms']) && is_numeric($baseline['ms'])) {
258 $ms = (int)$baseline['ms'];
259 if ($firstMs === null) {
260 $firstMs = $ms;
261 }
262 $lastMs = $ms;
263 }
264 }
265 return array(
266 'baselineControlCount' => $count,
267 'baselineControlOkCount' => $okCount,
268 'baselineControlFirstMs' => $firstMs ?? -1,
269 'baselineControlLastMs' => $lastMs ?? -1,
270 'baselineControlTrendMs' => $firstMs !== null && $lastMs !== null
271 ? $lastMs - $firstMs : 0,
272 );
273 }
274 }
275