File size: 5,441 Bytes
d2a6765
 
 
 
 
 
 
 
 
 
 
 
6eb59bb
 
 
 
 
 
d2a6765
 
6eb59bb
 
 
 
 
 
d2a6765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6beb2d
d2a6765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1aecdd
 
d2a6765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Value normalization and per-field comparison for evaluation (pure, no I/O).

Before predicted and gold values are compared they must be normalized so that
cosmetic differences (casing, whitespace, currency symbols, date formats, the
JSON round-trip through the cache) do not count as errors -- exactly the
normalization the evaluation methodology calls for (data spec section 6, step 2).

The normalization reuses the pipeline's own coercion helpers so eval judges
values the same way the pipeline produces them:

- **money** fields go through the schema's number coercion and are compared for
  **exact equality at cent precision** (``round(x, 2)``). This deliberately does
  *not* reuse the validation module's ``money_close``: that check carries a
  rounding allowance whose purpose is to absorb accumulated cent rounding in the
  H2/H3 arithmetic cross-checks (data spec section 3), which has no place in a
  single gold-vs-prediction comparison -- any allowance at all would score a
  materially-wrong total as correct and overstate the ``total``/``tax``
  auto-accept precision this harness exists to measure. Section 6 asks only for
  normalization then comparison; cent-exact equality is that comparison, with the
  cent rounding absorbing sub-cent floating-point representation noise.

  ``money_close`` formerly scaled by 0.5% of the amount, which is where the
  "502.00 vs 500.00" regression test below comes from. That relative term has
  since been removed from validation too (FC-2 in ``eval/FINDINGS.md``); the
  separation of concerns here stands regardless of what the validation
  allowance is.
- **date** fields go through the schema's date coercion (day-first for ambiguous
  D/M/Y, matching SROIE) and are compared for exact ISO-date equality.
- **text** fields are lower-cased and whitespace-collapsed, then compared for
  exact equality.

A value that normalizes to ``None`` (absent, blank, or unparseable) is treated as
"not present": it can never match, so predicting a value where the gold is absent
counts against precision, and missing a gold value counts against recall.
"""

from __future__ import annotations

import re
from datetime import date
from typing import Any

from docfield.schema.models import _coerce_date, _coerce_number

# How each schema field is compared. Fields not listed default to "text".
FIELD_KIND: dict[str, str] = {
    "doc_type": "text",
    "vendor_name": "text",
    "vendor_address": "text",
    "invoice_number": "text",
    "currency": "text",
    "document_date": "date",
    "due_date": "date",
    "subtotal": "money",
    "tax": "money",
    "total": "money",
}


def _normalize_text(value: Any) -> str | None:
    """Lower-case and whitespace-collapse a text value; blanks become ``None``."""
    if value is None:
        return None
    collapsed = re.sub(r"\s+", " ", str(value).strip().lower())
    return collapsed or None


def _normalize_money(value: Any) -> float | None:
    """Coerce a monetary value to ``float``; unparseable/absent becomes ``None``."""
    try:
        return _coerce_number(value)
    except (ValueError, TypeError):
        return None


def _normalize_date(value: Any) -> date | None:
    """Coerce a date value to ``datetime.date``; unparseable/absent becomes ``None``."""
    try:
        return _coerce_date(value)
    except (ValueError, TypeError):
        return None


def normalize(field: str, value: Any) -> Any:
    """Normalize a single value according to its field's comparison kind.

    Args:
        field: The ``Document`` field name.
        value: The raw predicted or gold value.

    Returns:
        A normalized comparable value (``float`` for money, ``date`` for dates,
        lower-cased string for text), or ``None`` when the value is absent,
        blank, or unparseable.
    """
    kind = FIELD_KIND.get(field, "text")
    if kind == "money":
        return _normalize_money(value)
    if kind == "date":
        return _normalize_date(value)
    return _normalize_text(value)


def is_present(field: str, value: Any) -> bool:
    """Whether ``value`` normalizes to a real (non-absent) value for ``field``.

    Args:
        field: The ``Document`` field name.
        value: The raw value to test.

    Returns:
        ``True`` if the value normalizes to something other than ``None``.
    """
    return normalize(field, value) is not None


def values_match(field: str, predicted: Any, gold: Any) -> bool:
    """Whether a predicted value matches the gold value for ``field``.

    Both sides are normalized first. A match requires both to be present;
    monetary fields match at cent precision (rounded to 2 dp, no relative
    tolerance), dates and text match on exact normalized equality.

    Args:
        field: The ``Document`` field name being compared.
        predicted: The pipeline's predicted value (possibly a JSON-cached form).
        gold: The dataset's ground-truth value.

    Returns:
        ``True`` if the values are considered equal after normalization.
    """
    left = normalize(field, predicted)
    right = normalize(field, gold)
    if left is None or right is None:
        return False
    if FIELD_KIND.get(field, "text") == "money":
        # Cent-exact: no relative tolerance, so a materially-wrong total is never
        # scored correct. round() absorbs sub-cent float representation noise.
        return round(left, 2) == round(right, 2)
    return left == right