204 lines
11 KiB
PHP
204 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
|
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
|
|
|
final class BaselineCompareExplanationRegistry
|
|
{
|
|
public function __construct(
|
|
private readonly OperatorExplanationBuilder $builder,
|
|
private readonly ReasonPresenter $reasonPresenter,
|
|
) {}
|
|
|
|
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
|
{
|
|
$reason = $stats->reasonCode !== null
|
|
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
|
|
: null;
|
|
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
|
|
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
|
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
|
$findingsCount = (int) ($stats->findingsCount ?? 0);
|
|
$executionOutcome = match ($stats->state) {
|
|
'comparing' => 'in_progress',
|
|
'failed' => 'failed',
|
|
default => $hasWarnings ? 'completed_with_follow_up' : 'completed',
|
|
};
|
|
$executionOutcomeLabel = match ($executionOutcome) {
|
|
'in_progress' => 'In progress',
|
|
'failed' => 'Execution failed',
|
|
'completed_with_follow_up' => 'Completed with follow-up',
|
|
default => 'Completed successfully',
|
|
};
|
|
$family = $reason?->absencePattern !== null
|
|
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
|
|
: null;
|
|
$family ??= match (true) {
|
|
$stats->state === 'comparing' => ExplanationFamily::InProgress,
|
|
$stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite,
|
|
$stats->state === 'no_tenant',
|
|
$stats->state === 'no_assignment',
|
|
$stats->state === 'no_snapshot',
|
|
$stats->state === 'idle' => ExplanationFamily::Unavailable,
|
|
$findingsCount === 0 && ! $hasWarnings => ExplanationFamily::NoIssuesDetected,
|
|
$hasWarnings => ExplanationFamily::CompletedButLimited,
|
|
default => ExplanationFamily::TrustworthyResult,
|
|
};
|
|
$trustworthiness = $reason?->trustImpact !== null
|
|
? TrustworthinessLevel::tryFrom($reason->trustImpact)
|
|
: null;
|
|
$trustworthiness ??= match (true) {
|
|
$family === ExplanationFamily::NoIssuesDetected,
|
|
$family === ExplanationFamily::TrustworthyResult => TrustworthinessLevel::Trustworthy,
|
|
$family === ExplanationFamily::CompletedButLimited => TrustworthinessLevel::LimitedConfidence,
|
|
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
|
|
default => TrustworthinessLevel::Unusable,
|
|
};
|
|
$evaluationResult = match ($family) {
|
|
ExplanationFamily::TrustworthyResult => 'full_result',
|
|
ExplanationFamily::NoIssuesDetected => 'no_result',
|
|
ExplanationFamily::SuppressedOutput => 'suppressed_result',
|
|
ExplanationFamily::MissingInput,
|
|
ExplanationFamily::BlockedPrerequisite,
|
|
ExplanationFamily::Unavailable,
|
|
ExplanationFamily::InProgress => 'unavailable',
|
|
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
|
|
? 'suppressed_result'
|
|
: 'incomplete_result',
|
|
};
|
|
$headline = match ($family) {
|
|
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
|
|
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
|
|
ExplanationFamily::CompletedButLimited => $findingsCount > 0
|
|
? 'The comparison found drift, but the result needs caution.'
|
|
: 'The comparison finished, but the current result is not an all-clear.',
|
|
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
|
|
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
|
|
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
|
|
ExplanationFamily::InProgress => 'The comparison is still running.',
|
|
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
|
|
};
|
|
$coverageStatement = match (true) {
|
|
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
|
|
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
|
|
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
|
|
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
|
|
$stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.',
|
|
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
|
|
default => 'Coverage matched the in-scope compare input for this run.',
|
|
};
|
|
$reliabilityStatement = match ($trustworthiness) {
|
|
TrustworthinessLevel::Trustworthy => $findingsCount > 0
|
|
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
|
|
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
|
|
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
|
|
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
|
|
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
|
|
};
|
|
$nextActionText = $reason?->firstNextStep()?->label ?? match ($family) {
|
|
ExplanationFamily::NoIssuesDetected => 'No action needed',
|
|
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
|
|
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
|
|
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
|
|
ExplanationFamily::InProgress => 'Wait for the compare to finish',
|
|
ExplanationFamily::MissingInput,
|
|
ExplanationFamily::BlockedPrerequisite,
|
|
ExplanationFamily::Unavailable => $stats->state === 'idle'
|
|
? 'Run the baseline compare to generate a result'
|
|
: 'Review the blocking baseline or scope prerequisite',
|
|
};
|
|
|
|
return $this->builder->build(
|
|
family: $family,
|
|
headline: $headline,
|
|
executionOutcome: $executionOutcome,
|
|
executionOutcomeLabel: $executionOutcomeLabel,
|
|
evaluationResult: $evaluationResult,
|
|
trustworthinessLevel: $trustworthiness,
|
|
reliabilityStatement: $reliabilityStatement,
|
|
coverageStatement: $coverageStatement,
|
|
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
|
|
dominantCauseLabel: $reason?->operatorLabel,
|
|
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
|
|
nextActionCategory: $family === ExplanationFamily::NoIssuesDetected
|
|
? 'none'
|
|
: match ($family) {
|
|
ExplanationFamily::TrustworthyResult => 'manual_validate',
|
|
ExplanationFamily::MissingInput,
|
|
ExplanationFamily::BlockedPrerequisite,
|
|
ExplanationFamily::Unavailable => 'fix_prerequisite',
|
|
default => 'review_evidence_gaps',
|
|
},
|
|
nextActionText: $nextActionText,
|
|
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
|
|
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
|
|
diagnosticsSummary: 'Run evidence and low-level compare diagnostics remain available below the primary explanation.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, CountDescriptor>
|
|
*/
|
|
private function countDescriptors(
|
|
BaselineCompareStats $stats,
|
|
bool $hasCoverageWarnings,
|
|
bool $hasEvidenceGaps,
|
|
): array {
|
|
$descriptors = [];
|
|
|
|
if ($stats->findingsCount !== null) {
|
|
$descriptors[] = new CountDescriptor(
|
|
label: 'Findings shown',
|
|
value: (int) $stats->findingsCount,
|
|
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
|
qualifier: $hasCoverageWarnings || $hasEvidenceGaps ? 'not complete' : null,
|
|
);
|
|
}
|
|
|
|
if ($stats->uncoveredTypesCount !== null) {
|
|
$descriptors[] = new CountDescriptor(
|
|
label: 'Uncovered types',
|
|
value: (int) $stats->uncoveredTypesCount,
|
|
role: CountDescriptor::ROLE_COVERAGE,
|
|
qualifier: (int) $stats->uncoveredTypesCount > 0 ? 'coverage gap' : null,
|
|
);
|
|
}
|
|
|
|
if ($stats->evidenceGapsCount !== null) {
|
|
$descriptors[] = new CountDescriptor(
|
|
label: 'Evidence gaps',
|
|
value: (int) $stats->evidenceGapsCount,
|
|
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
|
|
qualifier: (int) $stats->evidenceGapsCount > 0 ? 'review needed' : null,
|
|
);
|
|
}
|
|
|
|
if ($stats->severityCounts !== []) {
|
|
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
|
|
$value = (int) ($stats->severityCounts[$key] ?? 0);
|
|
|
|
if ($value === 0) {
|
|
continue;
|
|
}
|
|
|
|
$descriptors[] = new CountDescriptor(
|
|
label: $label,
|
|
value: $value,
|
|
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
|
|
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
|
|
);
|
|
}
|
|
}
|
|
|
|
return $descriptors;
|
|
}
|
|
}
|