$attempts * Chronological per-request outcomes for the real table endpoint's own * workload-matched A/B attempts, already parsed at the journal boundary * by ABJ_404_Solution_DetachAbAttempt::fromJournalRecord(). This rule * re-inspects nothing: the mode is MODE_ON or MODE_OFF by construction, * and an attempt either has pair coordinates or does not. * @return array */ public static function fromAttempts(array $attempts): array { $tally = self::tallyDetachAbAttempts($attempts); $onCount = $tally['on']; $onOkCount = $tally['onOk']; $offCount = $tally['off']; $offOkCount = $tally['offOk']; $pairs = self::matchedDetachAbPairs($attempts); $pairCount = count($pairs); $onFirstPairs = 0; $offFirstPairs = 0; $detachPairs = 0; $transientPairs = 0; $neitherPairs = 0; foreach ($pairs as $pair) { $pair['on_first'] ? $onFirstPairs++ : $offFirstPairs++; if ($pair['on_ok'] && !$pair['off_ok']) { $detachPairs++; } else if ($pair['on_ok'] && $pair['off_ok']) { $transientPairs++; } else if (!$pair['on_ok'] && !$pair['off_ok']) { $neitherPairs++; } } $orderCounterbalanced = $onFirstPairs > 0 && $offFirstPairs > 0; // ONE discriminant, then the flags read off it. Written as four // independent booleans this was a shape in which "detach is the cause" // and "neither mode helps" could both be true, or all four false: not // reachable through today's arithmetic, but nothing prevented it, and a // reader had to re-derive which single outcome was meant. The support // renderer was already doing exactly that -- looping the flag names to // recover one word -- which is the shape asking to be a discriminant. $verdict = self::VERDICT_INCONCLUSIVE; if ($pairCount >= 2 && $orderCounterbalanced && $detachPairs === $pairCount) { $verdict = self::VERDICT_DETACH_CAUSAL; } else if ($pairCount > 0 && $transientPairs === $pairCount) { $verdict = self::VERDICT_TRANSIENT_CAUSAL; } else if ($pairCount > 0 && $neitherPairs === $pairCount) { $verdict = self::VERDICT_NEITHER_MODE_HELPS; } return array( 'verdict' => $verdict, // WIRE SERIALIZATION of the discriminant above, not a second source // of truth. `verdict` is authoritative; in-process code branches on // it and never on these. They exist because they are published // support-payload fields (docs/diagnostic-catalog.md, payload schema // version 2, and already present in captured payloads), so they // cannot be dropped without a schema bump -- a reader that only // knows the boolean names must keep working. // // The previous note here claimed exactly one is true "by // construction". That is true at the moment this array is built and // says nothing afterwards: this is a mutable array, so once it is // returned, an edit to `verdict` alone, or to one flag alone, makes // the record contradict itself, and no accessor is standing between // a caller and that. The guarantee is enforced by // DetachAbVerdictTest::testTheDerivedFlagsAlwaysAgreeWithTheDiscriminant // across every reachable verdict, not by this comment. 'detachCausal' => $verdict === self::VERDICT_DETACH_CAUSAL, 'transientCausal' => $verdict === self::VERDICT_TRANSIENT_CAUSAL, 'neitherModeHelps' => $verdict === self::VERDICT_NEITHER_MODE_HELPS, 'inconclusive' => $verdict === self::VERDICT_INCONCLUSIVE, 'onCount' => $onCount, 'onOkCount' => $onOkCount, 'offCount' => $offCount, 'offOkCount' => $offOkCount, 'matchedPairCount' => $pairCount, 'onFirstPairCount' => $onFirstPairs, 'offFirstPairCount' => $offFirstPairs, 'orderCounterbalanced' => $orderCounterbalanced, ); } /** * Complete, workload-matched pairs only. Missing partners, legacy records * without scope fields, duplicated positions, and malformed mode pairs * remain visible in raw attempt accounting but cannot decide causality. * * @param array $attempts * @return array */ private static function matchedDetachAbPairs(array $attempts): array { $grouped = array(); $duplicates = array(); foreach ($attempts as $attempt) { $slot = $attempt->pairSlot(); if ($slot === null) { continue; } if (isset($grouped[$slot['key']][$slot['position']])) { $duplicates[$slot['key']] = true; continue; } $grouped[$slot['key']][$slot['position']] = $attempt; } $pairs = array(); foreach ($grouped as $key => $positions) { if (isset($duplicates[$key]) || !isset($positions[0], $positions[1]) || $positions[0]->mode() === $positions[1]->mode()) { continue; } $onFirst = $positions[0]->mode() === ABJ_404_Solution_DetachAbAttempt::MODE_ON; $on = $onFirst ? $positions[0] : $positions[1]; $off = $onFirst ? $positions[1] : $positions[0]; $pairs[] = array( 'on_ok' => $on->completed() === true, 'off_ok' => $off->completed() === true, 'on_first' => $onFirst, ); } return $pairs; } /** * Count per-mode attempts and completions, split out of * fromAttempts() purely to keep that method's cyclomatic * complexity within the project's ceiling -- this loop is one * self-contained tally, not logic that needs to be inlined at the call * site. * * @param array $attempts * @return array{on: int, onOk: int, off: int, offOk: int} */ private static function tallyDetachAbAttempts(array $attempts): array { $tally = array('on' => 0, 'onOk' => 0, 'off' => 0, 'offOk' => 0); foreach ($attempts as $attempt) { // Named explicitly rather than built by concatenating the mode onto // 'Ok'. A constructed key is a second, unchecked spelling of the // protocol: rename a mode and the tally silently starts counting // into a field nothing reads, with every total reading zero. $completed = $attempt->completed() === true; if ($attempt->mode() === ABJ_404_Solution_DetachAbAttempt::MODE_ON) { $tally['on']++; $tally['onOk'] += $completed ? 1 : 0; continue; } $tally['off']++; $tally['offOk'] += $completed ? 1 : 0; } return $tally; } }