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 / DetachAbAttempt.php

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

278 lines 10.4 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 * One detach A/B attempt, parsed once at the journal boundary (Bruno timeout
9 * cause matrix, gap G9 / c434).
10 *
11 * The experiment's vocabulary and its parse used to live in three places at
12 * once. ABJ_404_Solution_AjaxRequestLedger wrote the raw strings 'on', 'off',
13 * 'inert' and 'default'; ABJ_404_Solution_DetachAbEvidence read a journal
14 * record and narrowed those strings plus an ordinal; and then
15 * ABJ_404_Solution_DetachAbVerdict narrowed the SAME values a second time,
16 * from mixed, after they had already been parsed -- twice more, in two
17 * separate private methods. Nothing joined the three definitions, so the
18 * producer's vocabulary and the consumer's could drift apart silently: a
19 * fifth mode, or a rename, would simply stop pairing and the verdict would
20 * read 'inconclusive' with no evidence that anything had gone wrong.
21 *
22 * This type is the single named definition. A record becomes an attempt
23 * exactly once, here; everything downstream reads typed accessors and never
24 * re-inspects mixed input. Whether an attempt can occupy a PAIR slot is a
25 * separate, derived question (pairSlot()), because the pair coordinates
26 * depend on optional workload fields that a legacy record may not carry --
27 * that is a domain property of the record, not a second validation of it.
28 *
29 * Immutable: the browser's outcome arrives later, on a different request, so
30 * withOutcome() returns a new attempt rather than mutating one that another
31 * caller may already hold.
32 */
33 final class ABJ_404_Solution_DetachAbAttempt {
34
35 /** The response detached the connection before finishing its work. */
36 const MODE_ON = 'on';
37
38 /** The response deliberately did not detach: the experiment's control. */
39 const MODE_OFF = 'off';
40
41 /**
42 * The session's bounded run is over and this request took the ordinary
43 * best-available path. Positive evidence, never a measurement.
44 */
45 const MODE_DEFAULT = 'default';
46
47 /**
48 * The experiment did not run at all for this request (an ordinary release
49 * build, or a client that sends no session id). Positive evidence, never a
50 * measurement.
51 */
52 const MODE_INERT = 'inert';
53
54 /** @var string */
55 private $requestId;
56
57 /** @var self::MODE_ON|self::MODE_OFF */
58 private $mode;
59
60 /** @var string */
61 private $part;
62
63 /** @var string */
64 private $payloadKey;
65
66 /** @var int Server-assigned attempt ordinal, or -1 when absent. */
67 private $ordinal;
68
69 /** @var int */
70 private $pairOrdinal;
71
72 /** @var int */
73 private $pairPosition;
74
75 /** @var bool|null null means the browser has not reported on this attempt. */
76 private $completed;
77
78 /**
79 * One keyed bag rather than eight positional parameters.
80 *
81 * Four of the fields are strings and three are ints, so a positional
82 * signature lets requestId and mode swap, or part and payloadKey, or any
83 * two of the three ordinals, with every type check still passing and the
84 * damage appearing far away: a transposed part/payloadKey silently changes
85 * every pair key, and a transposed ordinal/pairPosition moves attempts into
86 * slots the server never assigned. PHP 7.4 is the floor for shipped code,
87 * so named arguments are not available; PHPStan checks this shape instead.
88 *
89 * @param array{
90 * requestId: string,
91 * mode: self::MODE_ON|self::MODE_OFF,
92 * part: string,
93 * payloadKey: string,
94 * ordinal: int,
95 * pairOrdinal: int,
96 * pairPosition: int,
97 * completed: bool|null
98 * } $fields
99 */
100 private function __construct(array $fields) {
101 $this->requestId = $fields['requestId'];
102 $this->mode = $fields['mode'];
103 $this->part = $fields['part'];
104 $this->payloadKey = $fields['payloadKey'];
105 $this->ordinal = $fields['ordinal'];
106 $this->pairOrdinal = $fields['pairOrdinal'];
107 $this->pairPosition = $fields['pairPosition'];
108 $this->completed = $fields['completed'];
109 }
110
111 /**
112 * This attempt's fields, for the two call sites that build a new one from
113 * them. Private, so the keyed shape never becomes part of the public
114 * surface: callers read the named accessors.
115 *
116 * @return array{requestId: string, mode: self::MODE_ON|self::MODE_OFF, part: string,
117 * payloadKey: string, ordinal: int, pairOrdinal: int, pairPosition: int,
118 * completed: bool|null}
119 */
120 private function fields(): array {
121 return array(
122 'requestId' => $this->requestId,
123 'mode' => $this->mode,
124 'part' => $this->part,
125 'payloadKey' => $this->payloadKey,
126 'ordinal' => $this->ordinal,
127 'pairOrdinal' => $this->pairOrdinal,
128 'pairPosition' => $this->pairPosition,
129 'completed' => $this->completed,
130 );
131 }
132
133 /**
134 * Every mode the ledger may assign, measurement and non-measurement alike.
135 *
136 * Callers that merely need to recognise a journaled mode (the resolution
137 * tracer's safe-value narrowing, for instance) read this rather than
138 * repeating the list, so adding a mode cannot leave one reader behind.
139 *
140 * @return array<int, string>
141 */
142 public static function everyMode(): array {
143 return array(self::MODE_INERT, self::MODE_ON, self::MODE_OFF, self::MODE_DEFAULT);
144 }
145
146 /**
147 * Parse one journaled mode record into an attempt, or null when the record
148 * is not a measurement.
149 *
150 * Returning null for MODE_INERT and MODE_DEFAULT is the rule, not a
151 * rejection: both are deliberately recorded as positive evidence that the
152 * experiment did not run, and folding either into the tally would compare
153 * the experiment against itself.
154 *
155 * @param array<array-key, mixed> $record One decoded journal line.
156 */
157 public static function fromJournalRecord(array $record): ?self {
158 $mode = self::scalarField($record, 'mode');
159 $requestId = self::scalarField($record, 'request_id');
160 if (($mode !== self::MODE_ON && $mode !== self::MODE_OFF) || $requestId === '') {
161 return null;
162 }
163 return new self(array(
164 'requestId' => $requestId,
165 'mode' => $mode,
166 'part' => self::scalarField($record, 'part'),
167 'payloadKey' => self::scalarField($record, 'payload_key'),
168 'ordinal' => self::intField($record, 'ordinal'),
169 'pairOrdinal' => self::intField($record, 'pair_ordinal'),
170 'pairPosition' => self::intField($record, 'pair_position'),
171 'completed' => null,
172 ));
173 }
174
175 /**
176 * The same attempt with the browser's verdict attached.
177 *
178 * @param bool|null $completed null when the browser has not reported yet:
179 * "has not said" and "said it failed" are opposite findings and only one
180 * of them belongs in a tally.
181 */
182 public function withOutcome(?bool $completed): self {
183 return new self(array('completed' => $completed) + $this->fields());
184 }
185
186 public function requestId(): string {
187 return $this->requestId;
188 }
189
190 /** @return self::MODE_ON|self::MODE_OFF Never anything else, by construction. */
191 public function mode(): string {
192 return $this->mode;
193 }
194
195 /** @return bool|null null when the browser has not reported on this attempt. */
196 public function completed(): ?bool {
197 return $this->completed;
198 }
199
200 /** Whether the browser has reported an outcome for this attempt at all. */
201 public function isResolved(): bool {
202 return $this->completed !== null;
203 }
204
205 /**
206 * Where this attempt sits in the counterbalanced sequence, or null when it
207 * carries no usable coordinates.
208 *
209 * Derived from the server-assigned ordinal alone. The supplemental
210 * pair_ordinal / pair_position fields are journaled for human reading and
211 * are deliberately NOT consulted here: a forged or corrupt pair position
212 * must never be able to move an attempt into a slot the server did not
213 * give it.
214 *
215 * @return array{key: string, position: int}|null
216 */
217 public function pairSlot(): ?array {
218 if ($this->part === '' || $this->payloadKey === '' || $this->ordinal < 0) {
219 return null;
220 }
221 return array(
222 'key' => $this->part . '|' . $this->payloadKey . '|' . intdiv($this->ordinal, 2),
223 'position' => $this->ordinal % 2,
224 );
225 }
226
227 /**
228 * The human-readable copy that travels on the journaled decision record.
229 *
230 * Field names and types are the wire format ABJ_404_Solution_DetachAbEvidence
231 * has always written, so an older reader of an existing journal keeps working.
232 *
233 * @return array<string, mixed>
234 */
235 public function toJournalArray(): array {
236 return array(
237 'request_id' => $this->requestId,
238 'mode' => $this->mode,
239 'part' => $this->part,
240 'payload_key' => $this->payloadKey,
241 'ordinal' => $this->ordinal,
242 'pair_ordinal' => $this->pairOrdinal,
243 'pair_position' => $this->pairPosition,
244 'ok' => $this->completed,
245 );
246 }
247
248 /**
249 * One record field as a string, or '' when it is absent or not scalar.
250 *
251 * @param array<array-key, mixed> $record
252 */
253 private static function scalarField(array $record, string $field): string {
254 $value = $record[$field] ?? null;
255 return is_scalar($value) ? (string)$value : '';
256 }
257
258 /**
259 * One record field as a non-negative int, or -1 when it is not one.
260 *
261 * -1 is the same "no usable coordinate" sentinel the ledger itself writes
262 * for an inert attempt, so an unreadable field and an absent one are the
263 * same finding downstream.
264 *
265 * Read through ABJ_404_Solution_ExactInteger rather than the is_numeric()
266 * plus cast this used to do. That pair accepts '1.9', '0.5', 1.0 and '1e1'
267 * and TRUNCATES each into a position the server never assigned: '0.5' and
268 * '1.9' would occupy positions 0 and 1 of the same pair and satisfy every
269 * "complete, matched, counterbalanced" condition the decision rule applies,
270 * manufacturing a causal verdict out of two corrupt journal lines.
271 *
272 * @param array<array-key, mixed> $record
273 */
274 private static function intField(array $record, string $field): int {
275 return ABJ_404_Solution_ExactInteger::readOr($record[$field] ?? null, 0, -1);
276 }
277 }
278