requestId = $fields['requestId']; $this->mode = $fields['mode']; $this->part = $fields['part']; $this->payloadKey = $fields['payloadKey']; $this->ordinal = $fields['ordinal']; $this->pairOrdinal = $fields['pairOrdinal']; $this->pairPosition = $fields['pairPosition']; $this->completed = $fields['completed']; } /** * This attempt's fields, for the two call sites that build a new one from * them. Private, so the keyed shape never becomes part of the public * surface: callers read the named accessors. * * @return array{requestId: string, mode: self::MODE_ON|self::MODE_OFF, part: string, * payloadKey: string, ordinal: int, pairOrdinal: int, pairPosition: int, * completed: bool|null} */ private function fields(): array { return array( 'requestId' => $this->requestId, 'mode' => $this->mode, 'part' => $this->part, 'payloadKey' => $this->payloadKey, 'ordinal' => $this->ordinal, 'pairOrdinal' => $this->pairOrdinal, 'pairPosition' => $this->pairPosition, 'completed' => $this->completed, ); } /** * Every mode the ledger may assign, measurement and non-measurement alike. * * Callers that merely need to recognise a journaled mode (the resolution * tracer's safe-value narrowing, for instance) read this rather than * repeating the list, so adding a mode cannot leave one reader behind. * * @return array */ public static function everyMode(): array { return array(self::MODE_INERT, self::MODE_ON, self::MODE_OFF, self::MODE_DEFAULT); } /** * Parse one journaled mode record into an attempt, or null when the record * is not a measurement. * * Returning null for MODE_INERT and MODE_DEFAULT is the rule, not a * rejection: both are deliberately recorded as positive evidence that the * experiment did not run, and folding either into the tally would compare * the experiment against itself. * * @param array $record One decoded journal line. */ public static function fromJournalRecord(array $record): ?self { $mode = self::scalarField($record, 'mode'); $requestId = self::scalarField($record, 'request_id'); if (($mode !== self::MODE_ON && $mode !== self::MODE_OFF) || $requestId === '') { return null; } return new self(array( 'requestId' => $requestId, 'mode' => $mode, 'part' => self::scalarField($record, 'part'), 'payloadKey' => self::scalarField($record, 'payload_key'), 'ordinal' => self::intField($record, 'ordinal'), 'pairOrdinal' => self::intField($record, 'pair_ordinal'), 'pairPosition' => self::intField($record, 'pair_position'), 'completed' => null, )); } /** * The same attempt with the browser's verdict attached. * * @param bool|null $completed null when the browser has not reported yet: * "has not said" and "said it failed" are opposite findings and only one * of them belongs in a tally. */ public function withOutcome(?bool $completed): self { return new self(array('completed' => $completed) + $this->fields()); } public function requestId(): string { return $this->requestId; } /** @return self::MODE_ON|self::MODE_OFF Never anything else, by construction. */ public function mode(): string { return $this->mode; } /** @return bool|null null when the browser has not reported on this attempt. */ public function completed(): ?bool { return $this->completed; } /** Whether the browser has reported an outcome for this attempt at all. */ public function isResolved(): bool { return $this->completed !== null; } /** * Where this attempt sits in the counterbalanced sequence, or null when it * carries no usable coordinates. * * Derived from the server-assigned ordinal alone. The supplemental * pair_ordinal / pair_position fields are journaled for human reading and * are deliberately NOT consulted here: a forged or corrupt pair position * must never be able to move an attempt into a slot the server did not * give it. * * @return array{key: string, position: int}|null */ public function pairSlot(): ?array { if ($this->part === '' || $this->payloadKey === '' || $this->ordinal < 0) { return null; } return array( 'key' => $this->part . '|' . $this->payloadKey . '|' . intdiv($this->ordinal, 2), 'position' => $this->ordinal % 2, ); } /** * The human-readable copy that travels on the journaled decision record. * * Field names and types are the wire format ABJ_404_Solution_DetachAbEvidence * has always written, so an older reader of an existing journal keeps working. * * @return array */ public function toJournalArray(): array { return array( 'request_id' => $this->requestId, 'mode' => $this->mode, 'part' => $this->part, 'payload_key' => $this->payloadKey, 'ordinal' => $this->ordinal, 'pair_ordinal' => $this->pairOrdinal, 'pair_position' => $this->pairPosition, 'ok' => $this->completed, ); } /** * One record field as a string, or '' when it is absent or not scalar. * * @param array $record */ private static function scalarField(array $record, string $field): string { $value = $record[$field] ?? null; return is_scalar($value) ? (string)$value : ''; } /** * One record field as a non-negative int, or -1 when it is not one. * * -1 is the same "no usable coordinate" sentinel the ledger itself writes * for an inert attempt, so an unreadable field and an absent one are the * same finding downstream. * * Read through ABJ_404_Solution_ExactInteger rather than the is_numeric() * plus cast this used to do. That pair accepts '1.9', '0.5', 1.0 and '1e1' * and TRUNCATES each into a position the server never assigned: '0.5' and * '1.9' would occupy positions 0 and 1 of the same pair and satisfy every * "complete, matched, counterbalanced" condition the decision rule applies, * manufacturing a causal verdict out of two corrupt journal lines. * * @param array $record */ private static function intField(array $record, string $field): int { return ABJ_404_Solution_ExactInteger::readOr($record[$field] ?? null, 0, -1); } }