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

1"""Reading a printed figure as a number. The only place that does it. 

2 

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. 

9 

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""" 

15 

16from __future__ import annotations 

17 

18import re 

19from decimal import Decimal, InvalidOperation 

20from typing import Optional 

21 

22 

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()) 

26 

27 

28def as_number(s: Optional[str]) -> Optional[Decimal]: 

29 """The numeric value of a printed figure, or None if it is not numeric. 

30 

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. 

36 

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 

58 

59 

60def is_percent(s: Optional[str]) -> bool: 

61 """Whether a printed figure is a percentage. 

62 

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("%")