TenantAtlas/app/Support/Baselines/BaselineCompareExplanationRegistry.php
2026-03-24 12:23:07 +01:00

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;
}
}