supportsOperatorExplanation()) { return null; } $artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run); $operatorExplanation ??= $artifactTruth?->operatorExplanation; $reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail'); if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) { return null; } $canonicalType = OperationCatalog::canonicalCode((string) $run->type); $context = is_array($run->context) ? $run->context : []; $counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []); $causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context); $dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope); $secondaryCauses = array_values(array_slice($causeCandidates, 1)); $artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation); $headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts); $primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope); $nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context); $nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope); $affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts); $secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText); return new GovernanceRunDiagnosticSummary( headline: $headline, executionOutcomeLabel: $this->executionOutcomeLabel($run), artifactImpactLabel: $artifactImpactLabel, primaryReason: $primaryReason, affectedScaleCue: $affectedScaleCue, nextActionCategory: $nextActionCategory, nextActionText: $nextActionText, dominantCause: [ 'code' => $dominantCause['code'] ?? null, 'label' => $dominantCause['label'], 'explanation' => $dominantCause['explanation'], ], secondaryCauses: array_map( static fn (array $cause): array => [ 'code' => $cause['code'] ?? null, 'label' => $cause['label'], 'explanation' => $cause['explanation'], ], $secondaryCauses, ), secondaryFacts: $secondaryFacts, diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false), ); } private function executionOutcomeLabel(OperationRun $run): string { $spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome); return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim((string) $run->outcome))); } private function artifactImpactLabel( ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, ): string { if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') { return $artifactTruth->primaryLabel; } if ($operatorExplanation instanceof OperatorExplanationPattern) { return $operatorExplanation->trustworthinessLabel(); } return 'Result needs review'; } /** * @param array{code: ?string, label: string, explanation: string} $dominantCause * @param array $context * @param array $counts */ private function headline( string $canonicalType, OperationRun $run, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, array $dominantCause, array $context, array $counts, ): string { return match ($canonicalType) { 'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation), 'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation), 'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation), 'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation), 'tenant.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation), default => $operatorExplanation?->headline ?? $artifactTruth?->primaryExplanation ?? 'This governance run needs review before it can be relied on.', }; } /** * @param array $context * @param array $counts */ private function baselineCaptureHeadline( ?ArtifactTruthEnvelope $artifactTruth, array $context, array $counts, ?OperatorExplanationPattern $operatorExplanation, ): string { $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $resumeToken = data_get($context, 'baseline_capture.resume_token'); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); if ($subjectsTotal === 0) { return 'No baseline was captured because no governed subjects were ready.'; } if (is_string($resumeToken) && trim($resumeToken) !== '') { return 'The baseline capture started, but more evidence still needs to be collected.'; } if ($gapCount > 0) { return 'The baseline capture finished, but evidence gaps still limit the snapshot.'; } if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') { return 'The baseline capture finished without a usable snapshot.'; } if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) { return 'The baseline capture finished without producing a decision-grade snapshot.'; } return $operatorExplanation?->headline ?? $artifactTruth?->primaryExplanation ?? 'The baseline capture needs review before it can be used.'; } /** * @param array $context * @param array $counts */ private function baselineCompareHeadline( ?ArtifactTruthEnvelope $artifactTruth, array $context, array $counts, ?OperatorExplanationPattern $operatorExplanation, ): string { $reasonCode = (string) data_get($context, 'baseline_compare.reason_code', ''); $proof = data_get($context, 'baseline_compare.coverage.proof'); $resumeToken = data_get($context, 'baseline_compare.resume_token'); if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) { return 'The compare finished, but ambiguous subject matching limited the result.'; } if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) { return 'The compare finished, but a compare strategy failure kept the result incomplete.'; } if (is_string($resumeToken) && trim($resumeToken) !== '') { return 'The compare finished, but evidence capture still needs to resume before the result is complete.'; } if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) { return 'The compare finished, but no decision-grade result is available yet.'; } if ($proof === false) { return 'The compare finished, but missing coverage proof suppressed the normal result.'; } return $operatorExplanation?->headline ?? $artifactTruth?->primaryExplanation ?? 'The compare needs follow-up before it can be treated as complete.'; } private function evidenceSnapshotHeadline( ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, ): string { return match (true) { $artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.', $artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.', $artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.', default => $operatorExplanation?->headline ?? $artifactTruth?->primaryExplanation ?? 'The evidence snapshot needs review before it is relied on.', }; } /** * @param array{code: ?string, label: string, explanation: string} $dominantCause */ private function reviewComposeHeadline( ?ArtifactTruthEnvelope $artifactTruth, array $dominantCause, ?OperatorExplanationPattern $operatorExplanation, ): string { return match (true) { $artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale' => 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.', $artifactTruth?->contentState === 'partial' => 'The review was generated, but required sections are still incomplete.', $artifactTruth?->freshnessState === 'stale' => 'The review was generated, but it relies on stale evidence.', default => $operatorExplanation?->headline ?? $dominantCause['explanation'] ?? 'The review needs follow-up before it should guide action.', }; } /** * @param array{code: ?string, label: string, explanation: string} $dominantCause */ private function reviewPackHeadline( ?ArtifactTruthEnvelope $artifactTruth, array $dominantCause, ?OperatorExplanationPattern $operatorExplanation, ): string { return match (true) { $artifactTruth?->publicationReadiness === 'blocked' => 'The pack did not produce a shareable artifact yet.', $artifactTruth?->publicationReadiness === 'internal_only' => 'The pack finished, but it should stay internal until the source review is refreshed.', default => $operatorExplanation?->headline ?? $dominantCause['explanation'] ?? 'The review pack needs follow-up before it is shared.', }; } /** * @param array{code: ?string, label: string, explanation: string} $dominantCause */ private function primaryReason( array $dominantCause, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, ?ReasonResolutionEnvelope $reasonEnvelope, ): string { return $dominantCause['explanation'] ?? $operatorExplanation?->dominantCauseExplanation ?? $reasonEnvelope?->shortExplanation ?? $artifactTruth?->primaryExplanation ?? $operatorExplanation?->reliabilityStatement ?? 'TenantPilot recorded diagnostic detail for this run.'; } /** * @param array $context */ private function nextActionCategory( string $canonicalType, OperationRun $run, ?ReasonResolutionEnvelope $reasonEnvelope, ?OperatorExplanationPattern $operatorExplanation, array $context, ): string { if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') { return 'retry_later'; } if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) { $resumeToken = $canonicalType === 'baseline.capture' ? data_get($context, 'baseline_capture.resume_token') : data_get($context, 'baseline_compare.resume_token'); if (is_string($resumeToken) && trim($resumeToken) !== '') { return 'resume_capture_or_generation'; } } $reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? ''); if (in_array($reasonCode, [ BaselineCompareReasonCode::AmbiguousSubjects->value, BaselineCompareReasonCode::UnsupportedSubjects->value, ], true)) { return 'review_scope_or_ambiguous_matches'; } if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) { return 'refresh_prerequisite_data'; } if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') { return 'no_further_action'; } if ( $reasonEnvelope?->actionability === 'prerequisite_missing' || in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'tenant.review.compose', 'tenant.review_pack.generate'], true) ) { return 'refresh_prerequisite_data'; } return 'manually_validate'; } private function nextActionText( ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, ?ReasonResolutionEnvelope $reasonEnvelope, ): string { $text = $operatorExplanation?->nextActionText ?? $artifactTruth?->nextStepText() ?? $reasonEnvelope?->firstNextStep()?->label ?? 'No action needed'; return trim(rtrim($text, '.')).'.'; } /** * @param array $context * @param array $counts * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function affectedScaleCue( string $canonicalType, OperationRun $run, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, array $context, array $counts, ): ?array { return match ($canonicalType) { 'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts), 'baseline.compare' => $this->baselineCompareScaleCue($context, $counts), 'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']), 'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []), 'tenant.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []), default => $this->summaryCountsScaleCue($counts), }; } /** * @param array $context * @param array $counts * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function baselineCaptureScaleCue(array $context, array $counts): ?array { $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); if ($gapCount > 0) { return [ 'label' => 'Affected subjects', 'value' => "{$gapCount} governed subjects still need evidence follow-up.", 'source' => 'context', 'confidence' => 'exact', ]; } if ($subjectsTotal >= 0) { return [ 'label' => 'Capture scope', 'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.", 'source' => 'context', 'confidence' => 'exact', ]; } return $this->summaryCountsScaleCue($counts); } /** * @param array $context * @param array $counts * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function baselineCompareScaleCue(array $context, array $counts): ?array { $gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count')); $subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total')); $uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string')); if ($gapCount > 0) { return [ 'label' => 'Affected subjects', 'value' => "{$gapCount} governed subjects still have evidence gaps.", 'source' => 'context', 'confidence' => 'exact', ]; } if ($uncoveredTypes !== []) { $count = count($uncoveredTypes); return [ 'label' => 'Coverage scope', 'value' => "{$count} policy types were left without proven compare coverage.", 'source' => 'context', 'confidence' => 'bounded', ]; } if ($subjectsTotal > 0) { return [ 'label' => 'Compare scope', 'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.", 'source' => 'context', 'confidence' => 'exact', ]; } return $this->summaryCountsScaleCue($counts); } /** * @param array $countDescriptors * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array { if ($artifactTruth?->contentState === 'partial') { $sections = $this->findCountDescriptor($countDescriptors, 'Sections'); if ($sections instanceof CountDescriptor) { return [ 'label' => 'Review sections', 'value' => "{$sections->value} sections were recorded and still need review for completeness.", 'source' => 'related_artifact_truth', 'confidence' => 'best_available', ]; } return [ 'label' => 'Review sections', 'value' => 'Required review sections are still incomplete.', 'source' => 'related_artifact_truth', 'confidence' => 'best_available', ]; } if ($artifactTruth?->freshnessState === 'stale') { return [ 'label' => 'Evidence freshness', 'value' => 'The source evidence is stale for at least part of this review.', 'source' => 'related_artifact_truth', 'confidence' => 'best_available', ]; } return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']); } /** * @param array $countDescriptors * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array { if ($artifactTruth?->publicationReadiness === 'internal_only') { return [ 'label' => 'Sharing scope', 'value' => 'The pack is suitable for internal follow-up only in its current state.', 'source' => 'related_artifact_truth', 'confidence' => 'best_available', ]; } return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']); } /** * @param array $countDescriptors * @param list $preferredLabels * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array { foreach ($preferredLabels as $label) { $descriptor = $this->findCountDescriptor($countDescriptors, $label); if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) { continue; } return [ 'label' => $descriptor->label, 'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.", 'source' => 'related_artifact_truth', 'confidence' => 'exact', ]; } return null; } /** * @param array $counts * @return array{label: string, value: string, source: string, confidence?: string}|null */ private function summaryCountsScaleCue(array $counts): ?array { foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) { $value = (int) ($counts[$key] ?? 0); if ($value <= 0) { continue; } return [ 'label' => SummaryCountsNormalizer::label($key), 'value' => "{$value} recorded in the canonical run counters.", 'source' => 'summary_counts', 'confidence' => 'exact', ]; } return null; } /** * @param array $context * @return list */ private function rankCauseCandidates( string $canonicalType, OperationRun $run, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, ?ReasonResolutionEnvelope $reasonEnvelope, array $context, ): array { $candidates = []; $this->pushCandidate( $candidates, code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode, label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel, explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation, rank: $this->reasonRank($reasonEnvelope, $operatorExplanation), ); match ($canonicalType) { 'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context), 'baseline.compare' => $this->baselineCompareCandidates($candidates, $context), 'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation), 'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth), 'tenant.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth), default => null, }; usort($candidates, static function (array $left, array $right): int { $rank = ($right['rank'] <=> $left['rank']); if ($rank !== 0) { return $rank; } return strcmp($left['label'], $right['label']); }); return array_values(array_map( static fn (array $candidate): array => [ 'code' => $candidate['code'], 'label' => $candidate['label'], 'explanation' => $candidate['explanation'], ], $candidates, )); } /** * @param list $candidates */ private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void { $label = is_string($label) ? trim($label) : ''; $explanation = is_string($explanation) ? trim($explanation) : ''; if ($label === '' || $explanation === '') { return; } foreach ($candidates as $candidate) { if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) { return; } } $candidates[] = [ 'code' => $code, 'label' => $label, 'explanation' => $explanation, 'rank' => $rank, ]; } /** * @param list $candidates * @param array $context */ private function baselineCaptureCandidates(array &$candidates, array $context): void { $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); $resumeToken = data_get($context, 'baseline_capture.resume_token'); if ($subjectsTotal === 0) { $this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95); } if ($gapCount > 0) { $this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82); } if (is_string($resumeToken) && trim($resumeToken) !== '') { $this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84); } } /** * @param list $candidates * @param array $context */ private function baselineCompareCandidates(array &$candidates, array $context): void { $reasonCode = (string) data_get($context, 'baseline_compare.reason_code', ''); $gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count')); $uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string')); $proof = data_get($context, 'baseline_compare.coverage.proof'); $resumeToken = data_get($context, 'baseline_compare.resume_token'); if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) { $this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92); } if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) { $this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94); } if ($gapCount > 0) { $this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83); } if ($proof === false || $uncoveredTypes !== []) { $count = count($uncoveredTypes); $explanation = $count > 0 ? "{$count} policy types were left without proven compare coverage." : 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.'; $this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81); } if (is_string($resumeToken) && trim($resumeToken) !== '') { $this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80); } } /** * @param list $candidates * @param array $countDescriptors */ private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void { $countDescriptors = $operatorExplanation?->countDescriptors ?? []; $missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions'); $stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions'); if ($missing instanceof CountDescriptor && $missing->value > 0) { $this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88); } if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) { $value = $stale instanceof CountDescriptor && $stale->value > 0 ? "{$stale->value} evidence dimensions are stale and should be refreshed." : 'Part of the evidence basis is stale and should be refreshed before use.'; $this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82); } } /** * @param list $candidates */ private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void { if ($artifactTruth?->contentState === 'partial') { $this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90); } if ($artifactTruth?->freshnessState === 'stale') { $this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86); } if ($artifactTruth?->publicationReadiness === 'blocked') { $this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95); } } /** * @param list $candidates */ private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void { if ($artifactTruth?->publicationReadiness === 'blocked') { $this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94); } if ($artifactTruth?->publicationReadiness === 'internal_only') { $this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80); } if ($artifactTruth?->freshnessState === 'stale') { $this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84); } if ($artifactTruth?->contentState === 'partial') { $this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86); } } private function reasonRank( ?ReasonResolutionEnvelope $reasonEnvelope, ?OperatorExplanationPattern $operatorExplanation, ): int { if ($reasonEnvelope?->actionability === 'retryable_transient') { return 76; } return match ($operatorExplanation?->nextActionCategory) { 'fix_prerequisite' => 92, 'retry_later' => 76, 'none' => 40, default => 85, }; } /** * @param list $secondaryCauses * @return list */ private function secondaryFacts( ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, array $secondaryCauses, string $nextActionCategory, string $nextActionText, ): array { $facts = []; if ($operatorExplanation instanceof OperatorExplanationPattern) { $facts[] = [ 'label' => 'Result trust', 'value' => $operatorExplanation->trustworthinessLabel(), 'hint' => $this->deduplicateSecondaryFactHint( $operatorExplanation->reliabilityStatement, $operatorExplanation->dominantCauseExplanation, $artifactTruth?->primaryExplanation, ), 'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value), ]; if ($operatorExplanation->evaluationResultLabel() !== '') { $facts[] = [ 'label' => 'Result meaning', 'value' => $operatorExplanation->evaluationResultLabel(), 'hint' => $operatorExplanation->coverageStatement, 'emphasis' => 'neutral', ]; } } if ($secondaryCauses !== []) { $facts[] = [ 'label' => 'Secondary causes', 'value' => implode(' ยท ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)), 'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.', 'emphasis' => 'caution', ]; } if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') { $facts[] = [ 'label' => 'Related artifact access', 'value' => 'No related artifact link is available from this run.', 'emphasis' => 'neutral', ]; } return $facts; } private function emphasisFromTrust(string $trust): string { return match ($trust) { 'unusable' => 'blocked', 'diagnostic_only', 'limited_confidence' => 'caution', default => 'neutral', }; } private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string { $normalizedHint = $this->normalizeFactText($hint); if ($normalizedHint === null) { return null; } foreach ($duplicates as $duplicate) { if ($normalizedHint === $this->normalizeFactText($duplicate)) { return null; } } return trim($hint ?? ''); } private function fallbackCause( ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation, ?ReasonResolutionEnvelope $reasonEnvelope, ): array { return [ 'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode, 'label' => $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel ?? 'Follow-up required', 'explanation' => $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation ?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.', ]; } private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor { foreach ($countDescriptors as $descriptor) { if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) { return $descriptor; } } return null; } private function intValue(mixed $value): ?int { return is_numeric($value) ? (int) $value : null; } private function pluralizeDescriptor(CountDescriptor $descriptor): string { return match ($descriptor->label) { 'Missing dimensions' => 'evidence dimensions are missing', 'Stale dimensions' => 'evidence dimensions are stale', 'Evidence dimensions' => 'evidence dimensions were recorded', 'Sections' => 'sections were recorded', 'Reports' => 'reports were recorded', 'Findings' => 'findings were recorded', default => strtolower($descriptor->label).' were recorded', }; } private function normalizeFactText(?string $value): ?string { if (! is_string($value)) { return null; } $normalized = trim((string) preg_replace('/\s+/', ' ', $value)); if ($normalized === '') { return null; } return mb_strtolower($normalized); } }