TenantAtlas/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php
Ahmed Darrazi c6cc58e1f3
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 43s
feat: add governance run summaries
2026-04-20 22:43:30 +02:00

914 lines
38 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationCatalog;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
final class GovernanceRunDiagnosticSummaryBuilder
{
public function __construct(
private readonly ArtifactTruthPresenter $artifactTruthPresenter,
private readonly ReasonPresenter $reasonPresenter,
) {}
public function build(
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth = null,
?OperatorExplanationPattern $operatorExplanation = null,
?ReasonResolutionEnvelope $reasonEnvelope = null,
): ?GovernanceRunDiagnosticSummary {
if (! $run->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<string, mixed> $context
* @param array<string, int> $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<string, mixed> $context
* @param array<string, int> $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<string, mixed> $context
* @param array<string, int> $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<string, mixed> $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<string, mixed> $context
* @param array<string, int> $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<string, mixed> $context
* @param array<string, int> $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<string, mixed> $context
* @param array<string, int> $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<int, CountDescriptor> $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<int, CountDescriptor> $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<int, CountDescriptor> $countDescriptors
* @param list<string> $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<string, int> $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<string, mixed> $context
* @return list<array{rank: int, code: ?string, label: string, explanation: string}>
*/
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<array{code: ?string, label: string, explanation: string, rank: int}> $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<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $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<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $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<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<int, CountDescriptor> $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<array{code: ?string, label: string, explanation: string, rank: int}> $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<array{code: ?string, label: string, explanation: string, rank: int}> $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<array{code: ?string, label: string, explanation: string}> $secondaryCauses
* @return list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }>
*/
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);
}
}