1, 'complete' => false, 'eligible_partial' => false, 'reason' => 'not_finished', 'path_count' => 0, 'gap_count' => 0, 'assessed_paths_digest' => hash( 'sha256', 'antivirus-ai-assessed-paths-v1' ), 'gaps_digest' => hash( 'sha256', 'antivirus-ai-size-gaps-v1' ) ); } public static function observe( array &$scan, string $location, array $checks, ?array $gap = null ): void { if ( 1 !== ( $scan['comparison_assessment']['schema_version'] ?? null ) ) { return; } $state =& $scan['comparison_assessment']; sort( $checks['structural'], SORT_STRING ); ksort( $checks, SORT_STRING ); // Queue observation order is deliberately bound: an order change fails closed. $state['assessed_paths_digest'] = hash( 'sha256', $state['assessed_paths_digest'] . "\0" . json_encode( array( base64_encode( $location ), $checks ), JSON_THROW_ON_ERROR ) ); ++$state['path_count']; if ( null !== $gap ) { if ( ! is_string( $gap['content_hash'] ?? null ) || ! preg_match( '/^[a-f0-9]{64}$/D', $gap['content_hash'] ) || ! is_int( $gap['bytes'] ?? null ) || $gap['bytes'] <= Detector::MAX_BYTES || $gap['bytes'] > FileSnapshot::MAX_BYTES ) { throw new \RuntimeException( 'Incomplete scan gap identity; partial comparison unavailable.' ); } $state['gaps_digest'] = hash( 'sha256', $state['gaps_digest'] . "\0" . json_encode( array( base64_encode( $location ), 'structural_size_limit', Detector::MAX_BYTES, $gap['bytes'], $gap['content_hash'] ), JSON_THROW_ON_ERROR ) ); ++$state['gap_count']; } } public static function finish( array &$scan ): void { if ( 1 !== ( $scan['comparison_assessment']['schema_version'] ?? null ) ) { return; } $state =& $scan['comparison_assessment']; $state['complete'] = 'finished' === ( $scan['phase'] ?? null ) && empty( $scan['target_finding_id'] ) && in_array( $scan['status'] ?? null, array( 'completed', 'incomplete' ), true ) && ! empty( $scan['review_context']['complete'] ) && ! empty( $scan['review_context']['stable'] ) && empty( $scan['observed_metadata_truncated'] ); $state['eligible_partial'] = $state['complete'] && 'incomplete' === $scan['status'] && 0 === ( $scan['counters']['unreadable'] ?? -1 ) && $state['gap_count'] > 0 && $state['gap_count'] === ( $scan['counters']['skipped'] ?? -1 ) && $state['gap_count'] === ( $scan['counters']['structural_incomplete'] ?? -1 ) && empty( $scan['coverage']['local_engine']['incomplete'] ); $state['reason'] = $state['eligible_partial'] ? 'size_limited_structure' : ( $state['complete'] && 'completed' === $scan['status'] ? 'completed_checks' : 'incompatible_or_incomplete_coverage' ); } }