File size: 4,428 Bytes
f07443e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
"""Has an entry changed in a way that invalidates an expert's ruling? (i3)

From the 2026-09-08 discussion: *"kalau dia sudah termutate harusnya dia perlu
review lagi… kalau tidak berubah, tetap approved."* The whole difficulty is in
the second half. Flipping everything a new document touches is easy and useless
— a document that merely mentions `PA` again moves `mention_count`, and if that
counts as a mutation the expert comes back to forty rows where nothing has
actually changed, finds that out one row at a time, and stops trusting the queue.

So the comparison is deliberately narrow: **only the fields an expert ruled on**
(decided 2026-09-14). Everything the pipeline computes about an entry — how
often it was mentioned, how it was classified, where it was found — can move
freely without disturbing an approval, because none of it is what the expert was
looking at when they approved.

Whitespace is collapsed before comparing, so re-extraction that reflows a
definition does not re-open it. Any other difference counts: this stays
conservative, because missing a real change means serving knowledge an expert
never agreed to, and that is the failure mode this exists to prevent.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

# What a reviewer is actually judging, per kind — the fields the review queue
# puts in front of them. Everything else on the entry is machinery.
RULED_FIELDS: dict[str, tuple[str, ...]] = {
    "glossary": ("term", "full_name", "definition", "source_wording"),
    "rule": ("statement", "condition", "consequence"),
    "formula": ("name", "formula_latex", "unit"),
    "document": ("title", "purpose_verbatim"),
    "domain": ("domain_name", "purpose_verbatim"),
}

# Pipeline bookkeeping. Named explicitly so the fallback below cannot mistake a
# recomputed count for a changed claim.
VOLATILE_FIELDS: frozenset[str] = frozenset(
    {
        "mention_count",
        "diff_status",
        "extraction_status",
        "definition_conflict",
        "conflict_variants",
        "provenance",
        "doc_id",
        "doc_ids",
    }
)


def _norm(value: Any) -> Any:
    """Collapse whitespace in strings; recurse into lists and dicts.

    Reflowed prose is not a changed claim. Nothing else is normalised — each
    further normalisation is a difference this would stop noticing.
    """
    if isinstance(value, str):
        return " ".join(value.split())
    if isinstance(value, list | tuple):
        return [_norm(v) for v in value]
    if isinstance(value, Mapping):
        return {k: _norm(v) for k, v in sorted(value.items())}
    return value


def ruled_content(kind: str, payload: Mapping[str, Any] | None) -> dict[str, Any]:
    """The subset of an entry that an approval actually covers.

    An unregistered kind falls back to the whole payload minus the volatile
    fields — conservative on purpose: a new kind should over-report changes
    until someone decides what its reviewer is looking at, never under-report.
    """
    payload = payload or {}
    fields = RULED_FIELDS.get(kind)
    if fields is not None:
        return {field: _norm(payload.get(field)) for field in fields}
    return {
        key: _norm(value)
        for key, value in sorted(payload.items())
        if key not in VOLATILE_FIELDS
    }


def changed_fields(
    kind: str,
    reviewed: Mapping[str, Any] | None,
    current: Mapping[str, Any] | None,
) -> list[str]:
    """Which ruled-on fields moved — the before/after a reviewer is shown (r4).

    Same comparison as `has_mutated`, reported field by field so the queue can
    say *what* changed instead of making the expert re-read the whole entry to
    find out. That was the ask: show before / after, do not send them back to
    the start.
    """
    before = ruled_content(kind, reviewed)
    after = ruled_content(kind, current)
    return [field for field in before if before[field] != after.get(field)]


def has_mutated(
    kind: str,
    reviewed: Mapping[str, Any] | None,
    current: Mapping[str, Any] | None,
) -> bool:
    """True when `current` differs from what was approved, in ways that matter.

    `reviewed` is what the expert had in front of them — for an `edited`
    decision that is their corrected payload, not the model's original claim.
    """
    return ruled_content(kind, reviewed) != ruled_content(kind, current)