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

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

222 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Which conclusion a counterbalanced detach A/B experiment actually supports
10 * (Bruno timeout cause matrix, gap G9 / c434).
11 *
12 * Its input is the request ledger's own per-attempt journal -- the real table
13 * endpoint's workload-matched 'on'/'off' attempts, as recorded by
14 * ABJ_404_Solution_DetachAbExperiment::assignNextAttempt() and
15 * ABJ_404_Solution_AjaxAdminEndpointSupport::checkpointedFlushAndFinish() --
16 * and its consumer is ABJ_404_Solution_DetachAbEvidence, which assembles the
17 * record this verdict travels in. Neither of those is the canary ladder.
18 *
19 * It lived on ABJ_404_Solution_AjaxCanaryLadder while that class was the only
20 * home for the matrix's pure rules, but the two verdicts were always
21 * deliberately disjoint: the ladder reads the BROWSER's per-step
22 * observations, this reads the SERVER's per-attempt outcomes, and keeping
23 * them apart is what stops an ambiguous quadrant in one leaking into the
24 * other's conclusion. Separate inputs and separate consumers make it a
25 * separate module, not a section of one.
26 *
27 * Pure and side-effect free, so the comparison logic is directly testable;
28 * the caller journals the result.
29 */
30 final class ABJ_404_Solution_DetachAbVerdict {
31
32 /** Detaching is what fixes it: every counterbalanced pair says so. */
33 const VERDICT_DETACH_CAUSAL = 'detachCausal';
34
35 /** Both modes succeeded in every pair, so the failure was transient. */
36 const VERDICT_TRANSIENT_CAUSAL = 'transientCausal';
37
38 /** Both modes failed in every pair, so the mode is not the variable. */
39 const VERDICT_NEITHER_MODE_HELPS = 'neitherModeHelps';
40
41 /**
42 * Not enough matched, counterbalanced evidence to say. The default, and
43 * deliberately reachable: a single mode with no pair to compare against
44 * must never be forced into one of the three findings above.
45 */
46 const VERDICT_INCONCLUSIVE = 'inconclusive';
47
48 /**
49 * The decisive-measurement rule for the detach A/B experiment (Bruno
50 * timeout cause matrix, gap G9 / c434;
51 * ABJ_404_Solution_DetachAbExperiment::assignNextAttempt() picks the
52 * mode, ABJ_404_Solution_AjaxAdminEndpointSupport::checkpointedFlushAndFinish()
53 * records it per request ID). Kept as its own pure function rather than
54 * folded into interpretResults(): two independent verdicts computed from
55 * disjoint inputs -- the ladder's canary observations vs. the
56 * real table endpoint's own A/B attempts -- can never confound each
57 * other, whereas merging them into one matrix would let an ambiguous
58 * quadrant in one leak into the other's conclusion.
59 *
60 * If every 'on' attempt completed and every 'off' attempt did not, the
61 * detach fix is causal. If both modes completed uniformly, detach was
62 * never the cause and a transient (or one of the other three things
63 * beta.2 also ships) is the better explanation. If neither mode ever
64 * completed, something else dominates regardless of detach. Anything
65 * else -- mixed outcomes within a mode, or fewer than one full pair
66 * observed -- is honestly inconclusive rather than forced into one of
67 * the three clean verdicts.
68 *
69 * @param array<int, ABJ_404_Solution_DetachAbAttempt> $attempts
70 * Chronological per-request outcomes for the real table endpoint's own
71 * workload-matched A/B attempts, already parsed at the journal boundary
72 * by ABJ_404_Solution_DetachAbAttempt::fromJournalRecord(). This rule
73 * re-inspects nothing: the mode is MODE_ON or MODE_OFF by construction,
74 * and an attempt either has pair coordinates or does not.
75 * @return array<string, mixed>
76 */
77 public static function fromAttempts(array $attempts): array {
78 $tally = self::tallyDetachAbAttempts($attempts);
79 $onCount = $tally['on'];
80 $onOkCount = $tally['onOk'];
81 $offCount = $tally['off'];
82 $offOkCount = $tally['offOk'];
83
84 $pairs = self::matchedDetachAbPairs($attempts);
85 $pairCount = count($pairs);
86 $onFirstPairs = 0;
87 $offFirstPairs = 0;
88 $detachPairs = 0;
89 $transientPairs = 0;
90 $neitherPairs = 0;
91 foreach ($pairs as $pair) {
92 $pair['on_first'] ? $onFirstPairs++ : $offFirstPairs++;
93 if ($pair['on_ok'] && !$pair['off_ok']) {
94 $detachPairs++;
95 } else if ($pair['on_ok'] && $pair['off_ok']) {
96 $transientPairs++;
97 } else if (!$pair['on_ok'] && !$pair['off_ok']) {
98 $neitherPairs++;
99 }
100 }
101
102 $orderCounterbalanced = $onFirstPairs > 0 && $offFirstPairs > 0;
103 // ONE discriminant, then the flags read off it. Written as four
104 // independent booleans this was a shape in which "detach is the cause"
105 // and "neither mode helps" could both be true, or all four false: not
106 // reachable through today's arithmetic, but nothing prevented it, and a
107 // reader had to re-derive which single outcome was meant. The support
108 // renderer was already doing exactly that -- looping the flag names to
109 // recover one word -- which is the shape asking to be a discriminant.
110 $verdict = self::VERDICT_INCONCLUSIVE;
111 if ($pairCount >= 2 && $orderCounterbalanced && $detachPairs === $pairCount) {
112 $verdict = self::VERDICT_DETACH_CAUSAL;
113 } else if ($pairCount > 0 && $transientPairs === $pairCount) {
114 $verdict = self::VERDICT_TRANSIENT_CAUSAL;
115 } else if ($pairCount > 0 && $neitherPairs === $pairCount) {
116 $verdict = self::VERDICT_NEITHER_MODE_HELPS;
117 }
118
119 return array(
120 'verdict' => $verdict,
121 // WIRE SERIALIZATION of the discriminant above, not a second source
122 // of truth. `verdict` is authoritative; in-process code branches on
123 // it and never on these. They exist because they are published
124 // support-payload fields (docs/diagnostic-catalog.md, payload schema
125 // version 2, and already present in captured payloads), so they
126 // cannot be dropped without a schema bump -- a reader that only
127 // knows the boolean names must keep working.
128 //
129 // The previous note here claimed exactly one is true "by
130 // construction". That is true at the moment this array is built and
131 // says nothing afterwards: this is a mutable array, so once it is
132 // returned, an edit to `verdict` alone, or to one flag alone, makes
133 // the record contradict itself, and no accessor is standing between
134 // a caller and that. The guarantee is enforced by
135 // DetachAbVerdictTest::testTheDerivedFlagsAlwaysAgreeWithTheDiscriminant
136 // across every reachable verdict, not by this comment.
137 'detachCausal' => $verdict === self::VERDICT_DETACH_CAUSAL,
138 'transientCausal' => $verdict === self::VERDICT_TRANSIENT_CAUSAL,
139 'neitherModeHelps' => $verdict === self::VERDICT_NEITHER_MODE_HELPS,
140 'inconclusive' => $verdict === self::VERDICT_INCONCLUSIVE,
141 'onCount' => $onCount,
142 'onOkCount' => $onOkCount,
143 'offCount' => $offCount,
144 'offOkCount' => $offOkCount,
145 'matchedPairCount' => $pairCount,
146 'onFirstPairCount' => $onFirstPairs,
147 'offFirstPairCount' => $offFirstPairs,
148 'orderCounterbalanced' => $orderCounterbalanced,
149 );
150 }
151
152 /**
153 * Complete, workload-matched pairs only. Missing partners, legacy records
154 * without scope fields, duplicated positions, and malformed mode pairs
155 * remain visible in raw attempt accounting but cannot decide causality.
156 *
157 * @param array<int, ABJ_404_Solution_DetachAbAttempt> $attempts
158 * @return array<int, array{on_ok: bool, off_ok: bool, on_first: bool}>
159 */
160 private static function matchedDetachAbPairs(array $attempts): array {
161 $grouped = array();
162 $duplicates = array();
163 foreach ($attempts as $attempt) {
164 $slot = $attempt->pairSlot();
165 if ($slot === null) {
166 continue;
167 }
168 if (isset($grouped[$slot['key']][$slot['position']])) {
169 $duplicates[$slot['key']] = true;
170 continue;
171 }
172 $grouped[$slot['key']][$slot['position']] = $attempt;
173 }
174
175 $pairs = array();
176 foreach ($grouped as $key => $positions) {
177 if (isset($duplicates[$key]) || !isset($positions[0], $positions[1])
178 || $positions[0]->mode() === $positions[1]->mode()) {
179 continue;
180 }
181 $onFirst = $positions[0]->mode() === ABJ_404_Solution_DetachAbAttempt::MODE_ON;
182 $on = $onFirst ? $positions[0] : $positions[1];
183 $off = $onFirst ? $positions[1] : $positions[0];
184 $pairs[] = array(
185 'on_ok' => $on->completed() === true,
186 'off_ok' => $off->completed() === true,
187 'on_first' => $onFirst,
188 );
189 }
190 return $pairs;
191 }
192
193 /**
194 * Count per-mode attempts and completions, split out of
195 * fromAttempts() purely to keep that method's cyclomatic
196 * complexity within the project's ceiling -- this loop is one
197 * self-contained tally, not logic that needs to be inlined at the call
198 * site.
199 *
200 * @param array<int, ABJ_404_Solution_DetachAbAttempt> $attempts
201 * @return array{on: int, onOk: int, off: int, offOk: int}
202 */
203 private static function tallyDetachAbAttempts(array $attempts): array {
204 $tally = array('on' => 0, 'onOk' => 0, 'off' => 0, 'offOk' => 0);
205 foreach ($attempts as $attempt) {
206 // Named explicitly rather than built by concatenating the mode onto
207 // 'Ok'. A constructed key is a second, unchecked spelling of the
208 // protocol: rename a mode and the tally silently starts counting
209 // into a field nothing reads, with every total reading zero.
210 $completed = $attempt->completed() === true;
211 if ($attempt->mode() === ABJ_404_Solution_DetachAbAttempt::MODE_ON) {
212 $tally['on']++;
213 $tally['onOk'] += $completed ? 1 : 0;
214 continue;
215 }
216 $tally['off']++;
217 $tally['offOk'] += $completed ? 1 : 0;
218 }
219 return $tally;
220 }
221 }
222