Coverage for src / quber / playground / answers / figures.py: 91%
23 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Reading a printed figure as a number. The only place that does it.
3A figure printed in a filing carries a currency sign, thousands separators, a
4percent sign, a trailing multiple, or parentheses standing in for a minus. Every
5consumer of an answer wants the number underneath: a spreadsheet column, a
6comparison against a gold value, an arithmetic check. Each of those wanting its
7own conversion is how the same figure comes out with two different values, and
8the way they differ is by sign.
10So there is one function, and everything that needs a number calls it. It is not
11a model's job either: the conversion is deterministic, and a model asked for it
12alongside the figure returns the figure reliably and the number only most of the
13time, which is a hole in a column no one can predict.
14"""
16from __future__ import annotations
18import re
19from decimal import Decimal, InvalidOperation
20from typing import Optional
23def norm_printed(s: Optional[str]) -> str:
24 """Trim and collapse whitespace, for comparing two printed figures."""
25 return re.sub(r"\s+", " ", (s or "").strip())
28def as_number(s: Optional[str]) -> Optional[Decimal]:
29 """The numeric value of a printed figure, or None if it is not numeric.
31 Parentheses mean negative, as financial statements print them. Currency
32 signs, thousands separators, percent signs and a trailing multiple 'x' are
33 dropped; a figure carrying a scale word keeps only its digits, so
34 '$1.2 million' reads as 1.2 and is compared against a gold that prints the
35 same way.
37 A percentage keeps the magnitude it was printed with: '92.6%' is 92.6, not
38 0.926. Nothing here rescales a figure, because a reader checking a row
39 against the filing is comparing it to what the page says.
40 """
41 if not s:
42 return None
43 # Currency, separators and spaces come off before the parentheses are read:
44 # a cell prints "$ (10,550)", so a parenthesis test applied first would see
45 # a string starting with "$" and miss the negative entirely.
46 t = re.sub(r"[$,%\s]", "", norm_printed(s))
47 neg = t.startswith("(") and t.endswith(")")
48 t = t.strip("()")
49 t = re.sub(r"[xX]$", "", t)
50 t = re.sub(r"(?i)(million|billion|thousand|mm|bn|[mbk])$", "", t)
51 if not re.match(r"^-?\d*\.?\d+$", t):
52 return None
53 try:
54 v = Decimal(t)
55 except InvalidOperation:
56 return None
57 return -v if neg else v
60def is_percent(s: Optional[str]) -> bool:
61 """Whether a printed figure is a percentage.
63 A spreadsheet given '92.6%' stores the fraction and formats the cell as a
64 percentage, so the figure both computes as a rate and displays as the
65 document printed it. Given a bare 92.6 it stores ninety-two point six, which
66 is not the rate. So an export has to know which figures keep their sign, and
67 this is the test — a question about notation, never about magnitude.
68 """
69 return norm_printed(s).endswith("%")