extractscore 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ """extractscore - field-level precision/recall/F1 for LLM-extracted JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .config import Config, FieldRule
8
+ from .diff import Diff, FieldDelta, Flip, diff
9
+ from .errors import ConfigError, ExtractScoreError, InputError
10
+ from .gate import GateFailure, check_gates
11
+ from .io import ParseFailure, load_jsonl
12
+ from .metrics import Counts, FieldStats
13
+ from .score import DocResult, Report, compare, evaluate, evaluate_files
14
+
15
+ __all__ = [
16
+ "Config",
17
+ "ConfigError",
18
+ "Counts",
19
+ "Diff",
20
+ "DocResult",
21
+ "ExtractScoreError",
22
+ "FieldDelta",
23
+ "FieldRule",
24
+ "FieldStats",
25
+ "Flip",
26
+ "GateFailure",
27
+ "InputError",
28
+ "ParseFailure",
29
+ "Report",
30
+ "__version__",
31
+ "check_gates",
32
+ "compare",
33
+ "diff",
34
+ "evaluate",
35
+ "evaluate_files",
36
+ "load_jsonl",
37
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
extractscore/align.py ADDED
@@ -0,0 +1,111 @@
1
+ """Pairing items of a list: pure-Python Hungarian algorithm, no dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Callable, Sequence
7
+
8
+ INF = float("inf")
9
+
10
+
11
+ def hungarian(cost: Sequence[Sequence[float]]) -> list[int]:
12
+ """Minimum-cost assignment on a square matrix; returns ``col`` for every ``row``.
13
+
14
+ O(n^3) shortest-augmenting-path version. Fine for the few hundred items a
15
+ document list usually has (200 items take a few tens of milliseconds).
16
+ """
17
+ n = len(cost)
18
+ u = [0.0] * (n + 1)
19
+ v = [0.0] * (n + 1)
20
+ owner = [0] * (n + 1)
21
+ way = [0] * (n + 1)
22
+ for i in range(1, n + 1):
23
+ owner[0] = i
24
+ j0 = 0
25
+ minv = [INF] * (n + 1)
26
+ used = [False] * (n + 1)
27
+ while True:
28
+ used[j0] = True
29
+ i0, delta, j1 = owner[j0], INF, 0
30
+ row = cost[i0 - 1]
31
+ for j in range(1, n + 1):
32
+ if not used[j]:
33
+ current = row[j - 1] - u[i0] - v[j]
34
+ if current < minv[j]:
35
+ minv[j], way[j] = current, j0
36
+ if minv[j] < delta:
37
+ delta, j1 = minv[j], j
38
+ for j in range(n + 1):
39
+ if used[j]:
40
+ u[owner[j]] += delta
41
+ v[j] -= delta
42
+ else:
43
+ minv[j] -= delta
44
+ j0 = j1
45
+ if owner[j0] == 0:
46
+ break
47
+ while j0:
48
+ j1 = way[j0]
49
+ owner[j0] = owner[j1]
50
+ j0 = j1
51
+ result = [0] * n
52
+ for j in range(1, n + 1):
53
+ result[owner[j] - 1] = j - 1
54
+ return result
55
+
56
+
57
+ def match_items(
58
+ gold: Sequence[dict],
59
+ pred: Sequence[dict],
60
+ similarity: Callable[[dict, dict], float],
61
+ threshold: float,
62
+ ) -> tuple[list[tuple[int, int]], list[int], list[int]]:
63
+ """Pair gold items with predicted items regardless of order.
64
+
65
+ Identical items are paired first (cheap, and optimal). The rest are paired by
66
+ maximising total similarity. A pair whose similarity is below ``threshold``
67
+ (or zero) is dropped, so both items stay unmatched - a gold item with nothing
68
+ like it counts as missing and the stray predicted item as hallucinated, instead
69
+ of a misleading "wrong value".
70
+
71
+ Returns ``(pairs, unmatched_gold_indices, unmatched_pred_indices)``.
72
+ """
73
+ pairs: list[tuple[int, int]] = []
74
+ pool: dict[str, list[int]] = {}
75
+ for j, item in enumerate(pred):
76
+ pool.setdefault(_key(item), []).append(j)
77
+ rest_gold: list[int] = []
78
+ taken: set[int] = set()
79
+ for i, item in enumerate(gold):
80
+ candidates = pool.get(_key(item))
81
+ if candidates:
82
+ j = candidates.pop(0)
83
+ pairs.append((i, j))
84
+ taken.add(j)
85
+ else:
86
+ rest_gold.append(i)
87
+ rest_pred = [j for j in range(len(pred)) if j not in taken]
88
+
89
+ if rest_gold and rest_pred:
90
+ size = max(len(rest_gold), len(rest_pred))
91
+ sims = [[0.0] * size for _ in range(size)]
92
+ for a, i in enumerate(rest_gold):
93
+ for b, j in enumerate(rest_pred):
94
+ sims[a][b] = similarity(gold[i], pred[j])
95
+ assignment = hungarian([[1.0 - s for s in row] for row in sims])
96
+ matched_gold: set[int] = set()
97
+ matched_pred: set[int] = set()
98
+ for a, b in enumerate(assignment):
99
+ if a < len(rest_gold) and b < len(rest_pred):
100
+ s = sims[a][b]
101
+ if s > 0.0 and s >= threshold:
102
+ pairs.append((rest_gold[a], rest_pred[b]))
103
+ matched_gold.add(rest_gold[a])
104
+ matched_pred.add(rest_pred[b])
105
+ rest_gold = [i for i in rest_gold if i not in matched_gold]
106
+ rest_pred = [j for j in rest_pred if j not in matched_pred]
107
+ return sorted(pairs), rest_gold, rest_pred
108
+
109
+
110
+ def _key(item: Any) -> str:
111
+ return json.dumps(item, sort_keys=True, ensure_ascii=False, default=str)
extractscore/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """Command line: ``extractscore score | diff | demo``.
2
+
3
+ Exit codes: 0 ok, 1 gate failed (``--min-f1``, ``--field-min``, ``--fail-on-regression``),
4
+ 2 usage or input error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import re
12
+ import shutil
13
+ import sys
14
+ import typing
15
+ from pathlib import Path
16
+ from typing import Sequence
17
+
18
+ from . import __version__
19
+ from .config import Config
20
+ from .diff import diff as diff_reports
21
+ from .errors import ExtractScoreError, InputError
22
+ from .gate import check_gates
23
+ from .i18n import LANGS, ref, resolve_lang, t
24
+ from .render import diff_markdown, diff_text, report_markdown, report_text
25
+ from .score import evaluate_files
26
+
27
+ SAMPLE_DIR = Path(__file__).resolve().parent / "sample"
28
+
29
+
30
+ def _early_lang(argv: Sequence[str]) -> str | None:
31
+ """Find ``--lang`` before the parser exists, so the help text itself can be translated."""
32
+ for i, arg in enumerate(argv):
33
+ if arg == "--lang" and i + 1 < len(argv):
34
+ return argv[i + 1]
35
+ if arg.startswith("--lang="):
36
+ return arg.split("=", 1)[1]
37
+ return None
38
+
39
+
40
+ _KIND_WORDS = {"int": "int", "float": "float"}
41
+
42
+
43
+ def _translate_argparse(message: str, lang: str) -> str:
44
+ """Translate argparse's own English messages (it has no translation hook)."""
45
+ if lang == "en":
46
+ return message
47
+ patterns = (
48
+ (r"the following arguments are required: (?P<names>.+)", "ap_required"),
49
+ (r"unrecognized arguments: (?P<names>.+)", "ap_unrecognized"),
50
+ (r"invalid choice: (?P<value>.+?) \(choose from (?P<choices>[^)]+)\)(?:, maybe you meant .+)?", "ap_invalid_choice"),
51
+ (r"invalid (?P<kind>\w+) value: (?P<value>.+)", "ap_invalid_value"),
52
+ (r"expected one argument", "ap_expected_one"),
53
+ )
54
+ named = re.fullmatch(r"argument (?P<name>[^:]+): (?P<rest>.+)", message, re.S)
55
+ if named:
56
+ return t(lang, "ap_argument", name=named["name"], message=_translate_argparse(named["rest"], lang))
57
+ for pattern, key in patterns:
58
+ found = re.fullmatch(pattern, message, re.S)
59
+ if found:
60
+ return t(lang, key, **found.groupdict())
61
+ return message
62
+
63
+
64
+ def _make_parser_class(lang: str) -> type[argparse.ArgumentParser]:
65
+ """An ArgumentParser whose usage prefix, group titles and usage errors follow ``lang``."""
66
+
67
+ class Formatter(argparse.HelpFormatter):
68
+ def add_usage(self, usage, actions, groups, prefix=None): # type: ignore[no-untyped-def]
69
+ super().add_usage(usage, actions, groups, t(lang, "usage_prefix") if prefix is None else prefix)
70
+
71
+ class Parser(argparse.ArgumentParser):
72
+ def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
73
+ kwargs.setdefault("formatter_class", Formatter)
74
+ kwargs["add_help"] = False
75
+ super().__init__(*args, **kwargs)
76
+ self._positionals.title = t(lang, "title_positional")
77
+ self._optionals.title = t(lang, "title_options")
78
+ self.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS, help=t(lang, "help_help"))
79
+
80
+ def error(self, message: str) -> typing.NoReturn:
81
+ self.print_usage(sys.stderr)
82
+ self.exit(2, f"{self.prog}: {t(lang, 'ap_error')}: {_translate_argparse(message, lang)}\n")
83
+
84
+ return Parser
85
+
86
+
87
+ def build_parser(lang: str) -> argparse.ArgumentParser:
88
+ """Build the argparse parser with all help text in ``lang``."""
89
+ parser_class = _make_parser_class(lang)
90
+ lang_parent = argparse.ArgumentParser(add_help=False)
91
+ lang_parent.add_argument("--lang", choices=LANGS, default=argparse.SUPPRESS, help=t(lang, "help_lang"))
92
+
93
+ parser = parser_class(prog="extractscore", description=t(lang, "help_description"), parents=[lang_parent])
94
+ parser.add_argument("--version", action="version", version=f"extractscore {__version__}", help=t(lang, "help_version"))
95
+ sub = parser.add_subparsers(dest="command", required=True, metavar="{score,diff,demo}", title=t(lang, "title_commands"), parser_class=parser_class)
96
+
97
+ score = sub.add_parser("score", help=t(lang, "help_score"), description=t(lang, "help_score"), parents=[lang_parent])
98
+ score.add_argument("gold", help=t(lang, "help_gold"))
99
+ score.add_argument("pred", help=t(lang, "help_pred"))
100
+ _common(score, lang)
101
+ score.add_argument("--min-f1", type=float, metavar="X", help=t(lang, "help_min_f1"))
102
+ score.add_argument("--field-min", action="append", default=[], metavar="PATH=X", help=t(lang, "help_field_min"))
103
+ score.add_argument("--top", type=int, default=5, metavar="N", help=t(lang, "help_top"))
104
+
105
+ diff = sub.add_parser("diff", help=t(lang, "help_diff"), description=t(lang, "help_diff"), parents=[lang_parent])
106
+ diff.add_argument("gold", help=t(lang, "help_gold"))
107
+ diff.add_argument("pred_a", metavar="A", help=t(lang, "help_pred_a"))
108
+ diff.add_argument("pred_b", metavar="B", help=t(lang, "help_pred_b"))
109
+ _common(diff, lang)
110
+ diff.add_argument("--fail-on-regression", action="store_true", help=t(lang, "help_fail_regress"))
111
+ diff.add_argument("--tolerance", type=float, default=0.0, metavar="X", help=t(lang, "help_tolerance"))
112
+
113
+ demo = sub.add_parser("demo", help=t(lang, "help_demo"), description=t(lang, "help_demo"), parents=[lang_parent])
114
+ demo.add_argument("--export", metavar="DIR", help=t(lang, "help_export"))
115
+ return parser
116
+
117
+
118
+ def _common(p: argparse.ArgumentParser, lang: str) -> None:
119
+ p.add_argument("--config", metavar="FILE", help=t(lang, "help_config"))
120
+ p.add_argument("--id-key", default="id", metavar="KEY", help=t(lang, "help_id_key"))
121
+ p.add_argument("--format", choices=("table", "json", "md"), default="table", help=t(lang, "help_format"))
122
+ p.add_argument("--out", metavar="FILE", help=t(lang, "help_out"))
123
+
124
+
125
+ def main(argv: Sequence[str] | None = None) -> int:
126
+ """Entry point; returns the process exit code."""
127
+ args_list = list(sys.argv[1:] if argv is None else argv)
128
+ lang = resolve_lang(_early_lang(args_list))
129
+ parser = build_parser(lang)
130
+ args = parser.parse_args(args_list)
131
+ lang = resolve_lang(getattr(args, "lang", None))
132
+ try:
133
+ if args.command == "score":
134
+ return _cmd_score(args, lang)
135
+ if args.command == "diff":
136
+ return _cmd_diff(args, lang)
137
+ return _cmd_demo(args, lang)
138
+ except ExtractScoreError as exc:
139
+ print(f"{t(lang, 'error_prefix')}: {exc.localized(lang)}", file=sys.stderr)
140
+ return 2
141
+
142
+
143
+ # ------------------------------------------------------------------ commands
144
+ def _load_config(path: str | None) -> Config:
145
+ return Config.from_file(path) if path else Config()
146
+
147
+
148
+ def _parse_field_min(items: Sequence[str]) -> dict[str, float]:
149
+ result: dict[str, float] = {}
150
+ for item in items:
151
+ path, sep, value = item.rpartition("=")
152
+ try:
153
+ number = float(value)
154
+ except ValueError:
155
+ number = -1.0
156
+ if not sep or not path or not 0.0 <= number <= 1.0:
157
+ raise InputError("err_bad_option", detail=ref("d_field_min_format", item=item))
158
+ result[path] = number
159
+ return result
160
+
161
+
162
+ def _emit(text: str, out: str | None, lang: str) -> None:
163
+ """Print to stdout, or write to ``--out`` and say so."""
164
+ if out is None:
165
+ sys.stdout.write(text)
166
+ return
167
+ try:
168
+ Path(out).write_text(text, encoding="utf-8")
169
+ except OSError as exc:
170
+ raise InputError("err_output_file", path=out, detail=str(exc)) from exc
171
+ print(t(lang, "wrote", path=out))
172
+
173
+
174
+ def _cmd_score(args: argparse.Namespace, lang: str) -> int:
175
+ if args.min_f1 is not None and not 0.0 <= args.min_f1 <= 1.0:
176
+ raise InputError("err_bad_option", detail=ref("d_min_f1_range"))
177
+ if args.top < 0:
178
+ raise InputError("err_bad_option", detail=ref("d_top_negative"))
179
+ field_min = _parse_field_min(args.field_min)
180
+ report = evaluate_files(args.gold, args.pred, _load_config(args.config), id_key=args.id_key)
181
+ gated = args.min_f1 is not None or bool(field_min)
182
+ failures = check_gates(report, min_f1=args.min_f1, field_min=field_min)
183
+
184
+ if args.format == "json":
185
+ data = report.to_dict(args.top)
186
+ if gated:
187
+ data["gate"] = {
188
+ "passed": not failures,
189
+ "failures": [{"field": f.path, "f1": f.f1, "minimum": f.minimum} for f in failures],
190
+ }
191
+ text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
192
+ elif args.format == "md":
193
+ text = report_markdown(report, lang, args.top, failures, gated)
194
+ else:
195
+ text = report_text(report, lang, args.top, failures, gated)
196
+ _emit(text, args.out, lang)
197
+ if args.out and gated: # keep the verdict visible in the CI log even when the report went to a file
198
+ for line in _gate_summary(failures, lang):
199
+ print(line)
200
+ return 1 if failures else 0
201
+
202
+
203
+ def _gate_summary(failures, lang: str) -> list[str]:
204
+ lines = [t(lang, "gate_fail_line", path=f.path, f1=f"{f.f1:.3f}", minimum=f"{f.minimum:.3f}") for f in failures]
205
+ lines.append(t(lang, "gate_fail", n=len(failures)) if failures else t(lang, "gate_pass"))
206
+ return lines
207
+
208
+
209
+ def _cmd_diff(args: argparse.Namespace, lang: str) -> int:
210
+ config = _load_config(args.config)
211
+ report_a = evaluate_files(args.gold, args.pred_a, config, id_key=args.id_key)
212
+ report_b = evaluate_files(args.gold, args.pred_b, config, id_key=args.id_key)
213
+ result = diff_reports(report_a, report_b, tolerance=args.tolerance)
214
+ if args.format == "json":
215
+ text = json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n"
216
+ elif args.format == "md":
217
+ text = diff_markdown(result, lang)
218
+ else:
219
+ text = diff_text(result, lang)
220
+ _emit(text, args.out, lang)
221
+ return 1 if args.fail_on_regression and result.regressions else 0
222
+
223
+
224
+ def _cmd_demo(args: argparse.Namespace, lang: str) -> int:
225
+ if args.export:
226
+ return _export_demo(Path(args.export), lang)
227
+ config = Config.from_file(SAMPLE_DIR / "fields.toml")
228
+ gold = SAMPLE_DIR / "gold.jsonl"
229
+ report_a = evaluate_files(gold, SAMPLE_DIR / "pred_a.jsonl", config)
230
+ report_b = evaluate_files(gold, SAMPLE_DIR / "pred_b.jsonl", config)
231
+ print(t(lang, "demo_banner"))
232
+ print()
233
+ print(f"=== {t(lang, 'demo_run_a')}")
234
+ sys.stdout.write(report_text(report_a, lang))
235
+ print()
236
+ print(f"=== {t(lang, 'demo_run_b')}")
237
+ sys.stdout.write(diff_text(diff_reports(report_a, report_b), lang))
238
+ print()
239
+ print(t(lang, "demo_next"))
240
+ return 0
241
+
242
+
243
+ def _export_demo(target: Path, lang: str) -> int:
244
+ if target.exists() and (not target.is_dir() or any(target.iterdir())):
245
+ raise InputError("err_export_exists", path=str(target))
246
+ shutil.copytree(SAMPLE_DIR, target, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__"))
247
+ print(t(lang, "demo_banner"))
248
+ print(t(lang, "demo_exported", path=target))
249
+ print(f" extractscore score {target}/gold.jsonl {target}/pred_a.jsonl --config {target}/fields.toml --min-f1 0.95")
250
+ print(f" extractscore diff {target}/gold.jsonl {target}/pred_a.jsonl {target}/pred_b.jsonl --config {target}/fields.toml --fail-on-regression")
251
+ return 0
252
+
253
+
254
+ if __name__ == "__main__": # pragma: no cover
255
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ """Typed value comparators.
2
+
3
+ Each comparator answers one question: is the predicted value the same as the gold
4
+ value, once format noise is removed? The answer is one of three outcomes:
5
+
6
+ * ``"match"`` - same value
7
+ * ``"wrong"`` - a different value
8
+ * ``"type_error"`` - the predicted value cannot be read as the field's type
9
+ (``"N/A"`` in a number field, an object in a date field...)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ from datetime import date, datetime
17
+ from typing import Any, Sequence
18
+
19
+ from .config import FieldRule
20
+
21
+ MATCH = "match"
22
+ WRONG = "wrong"
23
+ TYPE_ERROR = "type_error"
24
+
25
+ _CURRENCY = re.compile(r"usd|krw|eur|gbp|jpy|[$€£₩¥원\s,]", re.IGNORECASE)
26
+ _TRUE = {"true", "yes", "y", "1"}
27
+ _FALSE = {"false", "no", "n", "0"}
28
+
29
+
30
+ def is_empty(value: Any, empty_as_null: bool = True) -> bool:
31
+ """True for ``None``; with ``empty_as_null`` also ``""``, ``[]`` and ``{}``."""
32
+ if value is None:
33
+ return True
34
+ if not empty_as_null:
35
+ return False
36
+ if isinstance(value, str):
37
+ return not value.strip()
38
+ if isinstance(value, (list, dict)):
39
+ return not value
40
+ return False
41
+
42
+
43
+ def to_number(value: Any) -> float | None:
44
+ """Read a number from an int/float or a string such as ``"$1,250.00"`` or ``"12,000원"``."""
45
+ if isinstance(value, bool):
46
+ return None
47
+ if isinstance(value, (int, float)):
48
+ number = float(value)
49
+ elif isinstance(value, str):
50
+ cleaned = _CURRENCY.sub("", value)
51
+ try:
52
+ number = float(cleaned)
53
+ except ValueError:
54
+ return None
55
+ else:
56
+ return None
57
+ return number if math.isfinite(number) else None
58
+
59
+
60
+ def to_date(value: Any, formats: Sequence[str]) -> date | None:
61
+ """Parse a date string with the first format that fits (order matters for ``11/03/2026``)."""
62
+ if not isinstance(value, str):
63
+ return None
64
+ text = value.strip()
65
+ for fmt in formats:
66
+ try:
67
+ return datetime.strptime(text, fmt).date()
68
+ except ValueError:
69
+ continue
70
+ return None
71
+
72
+
73
+ def to_bool(value: Any) -> bool | None:
74
+ """Read a bool from a JSON bool or common strings (true/false/yes/no/1/0)."""
75
+ if isinstance(value, bool):
76
+ return value
77
+ if isinstance(value, (int, float)) and value in (0, 1):
78
+ return bool(value)
79
+ if isinstance(value, str):
80
+ lowered = value.strip().casefold()
81
+ if lowered in _TRUE:
82
+ return True
83
+ if lowered in _FALSE:
84
+ return False
85
+ return None
86
+
87
+
88
+ def normalise_text(value: Any, casefold: bool = True) -> str:
89
+ """Collapse whitespace and (optionally) ignore case."""
90
+ text = re.sub(r"\s+", " ", str(value)).strip()
91
+ return text.casefold() if casefold else text
92
+
93
+
94
+ def levenshtein(a: str, b: str) -> int:
95
+ """Edit distance between two strings."""
96
+ if a == b:
97
+ return 0
98
+ if not a:
99
+ return len(b)
100
+ if not b:
101
+ return len(a)
102
+ previous = list(range(len(b) + 1))
103
+ for i, char_a in enumerate(a, 1):
104
+ current = [i]
105
+ for j, char_b in enumerate(b, 1):
106
+ current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (char_a != char_b)))
107
+ previous = current
108
+ return previous[-1]
109
+
110
+
111
+ def text_similarity(a: str, b: str) -> float:
112
+ """1.0 for identical strings, falling with edit distance (normalised by the longer one)."""
113
+ longest = max(len(a), len(b))
114
+ return 1.0 if longest == 0 else 1.0 - levenshtein(a, b) / longest
115
+
116
+
117
+ def _is_container(value: Any) -> bool:
118
+ return isinstance(value, (dict, list))
119
+
120
+
121
+ def compare_values(rule: FieldRule, gold: Any, pred: Any, date_formats: Sequence[str]) -> str:
122
+ """Compare two non-empty scalar values under ``rule`` and return the outcome."""
123
+ kind = rule.type or "text"
124
+ if _is_container(pred):
125
+ return TYPE_ERROR
126
+
127
+ if kind == "number":
128
+ gold_n = to_number(gold)
129
+ if gold_n is None: # unreadable gold: fall back to plain text equality
130
+ return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
131
+ pred_n = to_number(pred)
132
+ if pred_n is None:
133
+ return TYPE_ERROR
134
+ return MATCH if math.isclose(gold_n, pred_n, rel_tol=1e-9, abs_tol=max(rule.tol, 1e-12)) else WRONG
135
+
136
+ if kind == "date":
137
+ gold_d = to_date(gold, date_formats)
138
+ if gold_d is None:
139
+ return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
140
+ pred_d = to_date(pred, date_formats)
141
+ if pred_d is None:
142
+ return TYPE_ERROR
143
+ return MATCH if gold_d == pred_d else WRONG
144
+
145
+ if kind == "bool":
146
+ gold_b = to_bool(gold)
147
+ if gold_b is None:
148
+ return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
149
+ pred_b = to_bool(pred)
150
+ if pred_b is None:
151
+ return TYPE_ERROR
152
+ return MATCH if gold_b == pred_b else WRONG
153
+
154
+ if kind == "id": # exact match; only surrounding whitespace is ignored
155
+ return MATCH if str(gold).strip() == str(pred).strip() else WRONG
156
+
157
+ if kind == "enum":
158
+ return MATCH if normalise_text(gold) == normalise_text(pred) else WRONG
159
+
160
+ # text
161
+ casefold = True if rule.casefold is None else rule.casefold
162
+ a, b = normalise_text(gold, casefold), normalise_text(pred, casefold)
163
+ if a == b:
164
+ return MATCH
165
+ if rule.fuzzy is not None and text_similarity(a, b) >= rule.fuzzy:
166
+ return MATCH
167
+ return WRONG