Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
CRAP
100.00% covered (success)
100.00%
1 / 1
reconciler
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
1 / 1
 resolve
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2// This file is part of Moodle - https://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16
17namespace local_doublemark\local;
18
19/**
20 * Turns two independent marks into a status and, when the rule allows, an agreed mark.
21 *
22 * Pure logic, no database: the store and the pages build on it.
23 *
24 * @package    local_doublemark
25 * @copyright  2026 DevLMS <contact@devlms.com>
26 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27 */
28final class reconciler {
29    /** One or both marks are still missing. */
30    public const MARKING = 'marking';
31    /** Both marks are in and the rule settled the agreed mark. */
32    public const AGREED = 'agreed';
33    /** Both marks are in and the lead has to decide. */
34    public const RECONCILE = 'reconcile';
35
36    /** Agreed mark is the mean of the two. */
37    public const AVERAGE = 'average';
38    /** Agreed mark is the higher of the two. */
39    public const HIGHER = 'higher';
40    /** Agreed mark is the first marker's. */
41    public const FIRST = 'first';
42    /** The lead always decides. */
43    public const LEAD = 'lead';
44
45    /** @var string[] Every rule, in the order the settings list them. */
46    public const RULES = [self::AVERAGE, self::HIGHER, self::FIRST, self::LEAD];
47
48    /**
49     * Status and agreed mark for a pair of marks.
50     *
51     * @param float|null $first First marker's mark, null when missing.
52     * @param float|null $second Second marker's mark, null when missing.
53     * @param float $tolerance Largest gap that still counts as agreement, in grade points (inclusive).
54     * @param string $rule One of RULES.
55     * @return array [status, agreed mark or null]
56     */
57    public static function resolve(?float $first, ?float $second, float $tolerance, string $rule): array {
58        if (!in_array($rule, self::RULES, true)) {
59            throw new \coding_exception("Unknown agreement rule '$rule'");
60        }
61        if ($first === null || $second === null) {
62            return [self::MARKING, null];
63        }
64        // Compare in hundredths so float noise cannot turn an exact gap into a disagreement.
65        $gap = abs((int) round($first * 100) - (int) round($second * 100));
66        if ($rule === self::LEAD || $gap > (int) round($tolerance * 100)) {
67            return [self::RECONCILE, null];
68        }
69        $agreed = match ($rule) {
70            self::AVERAGE => round(($first + $second) / 2, 2),
71            self::HIGHER => max($first, $second),
72            self::FIRST => $first,
73        };
74        return [self::AGREED, $agreed];
75    }
76}