Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConsensusScorer
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 5
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 score
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
90
 compareScore
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 pickConsensus
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 describeDisagreement
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7/**
8 * Cross-browser PDF oracle scorer (CSS Color 4 §17 / docs/plans/cross-
9 * browser-oracle.md). Given rasterised PNGs of our PDF plus one or more
10 * browser-engine PDFs, computes pairwise pixel-AE diffs and renders a
11 * verdict per the three-way consensus rule:
12 *
13 *  - If at least two browser engines agree (within
14 *    {@see self::BROWSER_AGREE_FUZZ}), the test is judged. They form
15 *    the *consensus set*.
16 *  - If fewer than two engines agree, return SKIP_DISAGREE — we can't
17 *    judge ours when the browsers themselves disagree (treated as a
18 *    browser-bug zone, not a test failure).
19 *  - When the consensus set is established, our render must be within
20 *    {@see self::OURS_FUZZ_GEOMETRY} (or the looser
21 *    {@see self::OURS_FUZZ_TEXT} for text-heavy fixtures) of every
22 *    engine in the set. Disagreement with any consensus engine fails.
23 *
24 * When only two engines are available (typical for Linux runners where
25 * `webkit` doesn't ship), the rule collapses to "the two must agree";
26 * one engine alone yields INSUFFICIENT_ENGINES.
27 *
28 * The scorer reuses {@see Scorer::diff()} for the underlying ImageMagick
29 * `compare -metric AE` pass.
30 */
31final class ConsensusScorer
32{
33    /**
34     * Browser-vs-browser agreement budget. Captures anti-aliasing,
35     * subpixel rounding, system font hinting. Tighten after Phase B
36     * empirical data; this initial value lets us classify obvious
37     * "engines disagree" cases without flagging routine drift.
38     */
39    public const BROWSER_AGREE_FUZZ = 0.02; // 2% pixel AE
40
41    /**
42     * Ours-vs-consensus budget for fixtures that are pure shapes /
43     * colours (no text). The print-options contract eliminates page-
44     * extent drift; what's left is rasterisation noise.
45     */
46    public const OURS_FUZZ_GEOMETRY = 0.005; // 0.5%
47
48    /**
49     * Ours-vs-consensus budget for fixtures that contain text. Looser
50     * because system font rasterisers disagree on hinting / subpixel
51     * positioning even when the layout matches.
52     */
53    public const OURS_FUZZ_TEXT = 0.05; // 5%
54
55    public function __construct(
56        private readonly Scorer $scorer = new Scorer(),
57    ) {}
58
59    /**
60     * Score `$oursPng` against the supplied engine renders.
61     *
62     * `$engines` keys are engine identifiers (`chromium`, `firefox`,
63     * `webkit`); values are PNG paths. Any engine may be missing
64     * (typical: `webkit` on Linux runners). Two or more engines must
65     * be present for a verdict; one yields INSUFFICIENT_ENGINES.
66     *
67     * `$fuzzBudget` picks the ours-vs-consensus budget; pass either
68     * {@see self::OURS_FUZZ_GEOMETRY} or {@see self::OURS_FUZZ_TEXT}.
69     *
70     * @param array<string, string> $engines
71     *
72     * @return array{
73     *   verdict: ConsensusVerdict,
74     *   reason: string,
75     *   consensus: list<string>,
76     *   pairs: array<string, array<string, float>>,
77     *   ours: array<string, float>,
78     * }
79     */
80    public function score(
81        string $oursPng,
82        array $engines,
83        float $fuzzBudget = self::OURS_FUZZ_GEOMETRY,
84    ): array {
85        $names = array_keys($engines);
86        sort($names);
87
88        if (count($names) < 2) {
89            return [
90                'verdict' => ConsensusVerdict::InsufficientEngines,
91                'reason' => count($names) === 1
92                    ? "only one engine ($names[0]); need two to form consensus"
93                    : 'no engines supplied; need at least two',
94                'consensus' => [],
95                'pairs' => [],
96                'ours' => [],
97            ];
98        }
99
100        // Pairwise browser agreements. Half-matrix to avoid duplicate
101        // compare calls; access via `pairs[a][b]` after the loop.
102        $pairs = [];
103        foreach ($names as $i => $a) {
104            $pairs[$a] = $pairs[$a] ?? [];
105            for ($j = $i + 1; $j < count($names); $j++) {
106                $b = $names[$j];
107                $pairs[$b] = $pairs[$b] ?? [];
108                $score = $this->compareScore($engines[$a], $engines[$b]);
109                $pairs[$a][$b] = $score;
110                $pairs[$b][$a] = $score;
111            }
112        }
113
114        // Build consensus set: engines whose pairwise AE with every
115        // other consensus member is within BROWSER_AGREE_FUZZ. Start
116        // with the engine that has the most under-budget neighbours.
117        $consensus = self::pickConsensus($names, $pairs, self::BROWSER_AGREE_FUZZ);
118        if (count($consensus) < 2) {
119            return [
120                'verdict' => ConsensusVerdict::SkipDisagree,
121                'reason' => self::describeDisagreement($names, $pairs),
122                'consensus' => $consensus,
123                'pairs' => $pairs,
124                'ours' => [],
125            ];
126        }
127
128        // Ours vs each consensus engine.
129        $ours = [];
130        $worst = 0.0;
131        $worstEngine = $consensus[0];
132        foreach ($consensus as $engine) {
133            $score = $this->compareScore($oursPng, $engines[$engine]);
134            $ours[$engine] = $score;
135            if ($score > $worst) {
136                $worst = $score;
137                $worstEngine = $engine;
138            }
139        }
140        if ($worst <= $fuzzBudget) {
141            return [
142                'verdict' => ConsensusVerdict::Pass,
143                'reason' => sprintf(
144                    'ours agrees with %s within %.2f%% (worst: %s at %.3f%%)',
145                    implode(' + ', $consensus),
146                    $fuzzBudget * 100.0,
147                    $worstEngine,
148                    $worst * 100.0,
149                ),
150                'consensus' => $consensus,
151                'pairs' => $pairs,
152                'ours' => $ours,
153            ];
154        }
155
156        return [
157            'verdict' => ConsensusVerdict::Fail,
158            'reason' => sprintf(
159                'ours diverges from %s consensus at %s (%.3f%% AE > %.2f%% budget)',
160                implode(' + ', $consensus),
161                $worstEngine,
162                $worst * 100.0,
163                $fuzzBudget * 100.0,
164            ),
165            'consensus' => $consensus,
166            'pairs' => $pairs,
167            'ours' => $ours,
168        ];
169    }
170
171    /**
172     * Run a single `compare -metric AE` against two PNG paths and
173     * return the AE score in [0, 1].
174     */
175    private function compareScore(string $a, string $b): float
176    {
177        $result = $this->scorer->diff($a, $b);
178        if (is_string($result['diffImage'] ?? null) && is_file($result['diffImage'])) {
179            @unlink($result['diffImage']);
180        }
181        return $result['score'];
182    }
183
184    /**
185     * Pick the largest subset of engines where every pair is within
186     * `$budget`. Greedy: start from the densest neighbourhood and
187     * accept additions that don't break agreement.
188     *
189     * @param list<string> $names
190     * @param array<string, array<string, float>> $pairs
191     * @return list<string>
192     */
193    private static function pickConsensus(array $names, array $pairs, float $budget): array
194    {
195        // Score each engine by how many under-budget neighbours it has.
196        $neighbourhoods = [];
197        foreach ($names as $name) {
198            $count = 0;
199            foreach ($pairs[$name] ?? [] as $score) {
200                if ($score <= $budget) {
201                    $count++;
202                }
203            }
204            $neighbourhoods[$name] = $count;
205        }
206        arsort($neighbourhoods);
207        $consensus = [];
208        foreach (array_keys($neighbourhoods) as $candidate) {
209            $ok = true;
210            foreach ($consensus as $member) {
211                if (($pairs[$candidate][$member] ?? PHP_FLOAT_MAX) > $budget) {
212                    $ok = false;
213                    break;
214                }
215            }
216            if ($ok) {
217                $consensus[] = $candidate;
218            }
219        }
220        return $consensus;
221    }
222
223    /**
224     * Build a human-readable "browsers disagree" reason that names
225     * the worst-offending pair.
226     *
227     * @param list<string> $names
228     * @param array<string, array<string, float>> $pairs
229     */
230    private static function describeDisagreement(array $names, array $pairs): string
231    {
232        $worst = 0.0;
233        $worstA = '';
234        $worstB = '';
235        foreach ($names as $i => $a) {
236            for ($j = $i + 1; $j < count($names); $j++) {
237                $b = $names[$j];
238                $score = $pairs[$a][$b] ?? 0.0;
239                if ($score > $worst) {
240                    $worst = $score;
241                    $worstA = $a;
242                    $worstB = $b;
243                }
244            }
245        }
246        return sprintf(
247            'browsers disagree (worst pair: %s vs %s at %.3f%% AE > %.2f%% budget)',
248            $worstA,
249            $worstB,
250            $worst * 100.0,
251            self::BROWSER_AGREE_FUZZ * 100.0,
252        );
253    }
254}