Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 114
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 / 114
0.00% covered (danger)
0.00%
0 / 5
702
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 / 73
0.00% covered (danger)
0.00%
0 / 1
110
 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     * `oursAll` records our AE against EVERY supplied engine — including
73     * engines dropped from the consensus set — so a per-engine report can
74     * tell "we diverge from this engine" apart from "this engine never
75     * ran". `ours` stays the consensus-only subset the verdict is built
76     * from.
77     *
78     * @return array{
79     *   verdict: ConsensusVerdict,
80     *   reason: string,
81     *   consensus: list<string>,
82     *   pairs: array<string, array<string, float>>,
83     *   ours: array<string, float>,
84     *   oursAll: array<string, float>,
85     * }
86     */
87    public function score(
88        string $oursPng,
89        array $engines,
90        float $fuzzBudget = self::OURS_FUZZ_GEOMETRY,
91    ): array {
92        $names = array_keys($engines);
93        sort($names);
94
95        // Our AE against every engine that rendered, consensus or not —
96        // the per-engine matrix reads this so a disagreeing engine shows
97        // its real divergence rather than a misleading "unavailable".
98        $oursAll = [];
99        foreach ($names as $engine) {
100            $oursAll[$engine] = $this->compareScore($oursPng, $engines[$engine]);
101        }
102
103        if (count($names) < 2) {
104            return [
105                'verdict' => ConsensusVerdict::InsufficientEngines,
106                'reason' => count($names) === 1
107                    ? "only one engine ($names[0]); need two to form consensus"
108                    : 'no engines supplied; need at least two',
109                'consensus' => [],
110                'pairs' => [],
111                'ours' => [],
112                'oursAll' => $oursAll,
113            ];
114        }
115
116        // Pairwise browser agreements. Half-matrix to avoid duplicate
117        // compare calls; access via `pairs[a][b]` after the loop.
118        $pairs = [];
119        foreach ($names as $i => $a) {
120            $pairs[$a] = $pairs[$a] ?? [];
121            for ($j = $i + 1; $j < count($names); $j++) {
122                $b = $names[$j];
123                $pairs[$b] = $pairs[$b] ?? [];
124                $score = $this->compareScore($engines[$a], $engines[$b]);
125                $pairs[$a][$b] = $score;
126                $pairs[$b][$a] = $score;
127            }
128        }
129
130        // Build consensus set: engines whose pairwise AE with every
131        // other consensus member is within BROWSER_AGREE_FUZZ. Start
132        // with the engine that has the most under-budget neighbours.
133        $consensus = self::pickConsensus($names, $pairs, self::BROWSER_AGREE_FUZZ);
134        if (count($consensus) < 2) {
135            return [
136                'verdict' => ConsensusVerdict::SkipDisagree,
137                'reason' => self::describeDisagreement($names, $pairs),
138                'consensus' => $consensus,
139                'pairs' => $pairs,
140                'ours' => [],
141                'oursAll' => $oursAll,
142            ];
143        }
144
145        // Ours vs each consensus engine (reuse the per-engine scores
146        // already computed above; the verdict only weighs consensus).
147        $ours = [];
148        $worst = 0.0;
149        $worstEngine = $consensus[0];
150        foreach ($consensus as $engine) {
151            $score = $oursAll[$engine];
152            $ours[$engine] = $score;
153            if ($score > $worst) {
154                $worst = $score;
155                $worstEngine = $engine;
156            }
157        }
158        if ($worst <= $fuzzBudget) {
159            return [
160                'verdict' => ConsensusVerdict::Pass,
161                'reason' => sprintf(
162                    'ours agrees with %s within %.2f%% (worst: %s at %.3f%%)',
163                    implode(' + ', $consensus),
164                    $fuzzBudget * 100.0,
165                    $worstEngine,
166                    $worst * 100.0,
167                ),
168                'consensus' => $consensus,
169                'pairs' => $pairs,
170                'ours' => $ours,
171                'oursAll' => $oursAll,
172            ];
173        }
174
175        return [
176            'verdict' => ConsensusVerdict::Fail,
177            'reason' => sprintf(
178                'ours diverges from %s consensus at %s (%.3f%% AE > %.2f%% budget)',
179                implode(' + ', $consensus),
180                $worstEngine,
181                $worst * 100.0,
182                $fuzzBudget * 100.0,
183            ),
184            'consensus' => $consensus,
185            'pairs' => $pairs,
186            'ours' => $ours,
187            'oursAll' => $oursAll,
188        ];
189    }
190
191    /**
192     * Run a single `compare -metric AE` against two PNG paths and
193     * return the AE score in [0, 1].
194     */
195    private function compareScore(string $a, string $b): float
196    {
197        $result = $this->scorer->diff($a, $b);
198        if (is_string($result['diffImage'] ?? null) && is_file($result['diffImage'])) {
199            @unlink($result['diffImage']);
200        }
201        return $result['score'];
202    }
203
204    /**
205     * Pick the largest subset of engines where every pair is within
206     * `$budget`. Greedy: start from the densest neighbourhood and
207     * accept additions that don't break agreement.
208     *
209     * @param list<string> $names
210     * @param array<string, array<string, float>> $pairs
211     * @return list<string>
212     */
213    private static function pickConsensus(array $names, array $pairs, float $budget): array
214    {
215        // Score each engine by how many under-budget neighbours it has.
216        $neighbourhoods = [];
217        foreach ($names as $name) {
218            $count = 0;
219            foreach ($pairs[$name] ?? [] as $score) {
220                if ($score <= $budget) {
221                    $count++;
222                }
223            }
224            $neighbourhoods[$name] = $count;
225        }
226        arsort($neighbourhoods);
227        $consensus = [];
228        foreach (array_keys($neighbourhoods) as $candidate) {
229            $ok = true;
230            foreach ($consensus as $member) {
231                if (($pairs[$candidate][$member] ?? PHP_FLOAT_MAX) > $budget) {
232                    $ok = false;
233                    break;
234                }
235            }
236            if ($ok) {
237                $consensus[] = $candidate;
238            }
239        }
240        return $consensus;
241    }
242
243    /**
244     * Build a human-readable "browsers disagree" reason that names
245     * the worst-offending pair.
246     *
247     * @param list<string> $names
248     * @param array<string, array<string, float>> $pairs
249     */
250    private static function describeDisagreement(array $names, array $pairs): string
251    {
252        $worst = 0.0;
253        $worstA = '';
254        $worstB = '';
255        foreach ($names as $i => $a) {
256            for ($j = $i + 1; $j < count($names); $j++) {
257                $b = $names[$j];
258                $score = $pairs[$a][$b] ?? 0.0;
259                if ($score > $worst) {
260                    $worst = $score;
261                    $worstA = $a;
262                    $worstB = $b;
263                }
264            }
265        }
266        return sprintf(
267            'browsers disagree (worst pair: %s vs %s at %.3f%% AE > %.2f%% budget)',
268            $worstA,
269            $worstB,
270            $worst * 100.0,
271            self::BROWSER_AGREE_FUZZ * 100.0,
272        );
273    }
274}