| 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 |
|