Skip to content

Commit 951fcbc

Browse files
fix(currency): address code review feedback on accounting negatives, identifier roles, and ambiguity handling
1 parent 02d2f48 commit 951fcbc

4 files changed

Lines changed: 159 additions & 9 deletions

File tree

‎src/freshdata/engine/context.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,7 @@ def infer_role(
139139
label = str(name)
140140
if _is_target_name(label, config):
141141
return "target"
142-
if label in config.id_columns or (
143-
_ID_NAME.search(label) and not _MONEY_NAME.search(label)
144-
):
142+
if label in config.id_columns or _ID_NAME.search(label):
145143
return "id"
146144
if is_datetime64_any_dtype(s):
147145
return "datetime"

‎src/freshdata/semantic/context.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def _build_info(
8181
currencies: tuple[str, ...] = (),
8282
n_nonnull_override: int | None = None,
8383
) -> SemanticColumnInfo:
84+
"""Build column semantic metadata from engine role and value distributions."""
8485
name = str(col)
8586
series = df[col]
8687
# On the native distinct path *series* holds only distinct values, so the

‎src/freshdata/semantic/experts.py‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def parse_number_words(text: str) -> int | None:
113113
#: consults this table. Deliberately small: a currency absent from it and not
114114
#: settled by structure is reported ambiguous rather than guessed.
115115
_COMMA_DECIMAL_CURRENCIES = frozenset({"EUR", "CHF"})
116+
_ACCOUNTING_NUM_RE = re.compile(r"^[+-]?\s*\d[\d\s.,']*$")
116117

117118

118119
def _valid_grouping(part: str, sep: str) -> bool:
@@ -215,22 +216,33 @@ def _split_amount(body: str, code: str | None) -> tuple[float | None, bool]:
215216
def parse_currency_parts(text: str) -> tuple[float | None, bool]:
216217
"""``(value, ambiguous)`` for a currency string.
217218
218-
Requires an explicit currency marker (symbol or ISO-ish code) so that a bare
219-
``"1,200"`` is left to ordinary dtype repair, not treated as money.
219+
Requires an explicit currency marker (symbol or ISO-ish code), or an
220+
accounting-negative parenthesized amount with valid accounting punctuation,
221+
so that bare numbers and unit strings are left to ordinary repair.
220222
"""
221223
s = text.strip()
222224
accounting_negative = s.startswith("(") and s.endswith(")")
223225
if accounting_negative:
224226
s = s[1:-1].strip()
225-
has_symbol = any(c in s for c in _CURRENCY_SYMBOLS)
227+
if not s:
228+
return None, False
226229
codes = {t.lower() for t in re.findall(r"[A-Za-z]+", s)}
230+
if codes - _CURRENCY_CODES:
231+
return None, False
232+
has_symbol = any(c in s for c in _CURRENCY_SYMBOLS)
227233
has_code = bool(codes & _CURRENCY_CODES)
228234
if not (has_symbol or has_code or accounting_negative):
229235
return None, False
236+
if (
237+
accounting_negative
238+
and not (has_symbol or has_code)
239+
and (not ("." in s or "," in s) or not _ACCOUNTING_NUM_RE.match(s))
240+
):
241+
return None, False
230242
body = re.sub(r"[A-Za-z$€£¥₹\s\u00a0\u202f']", "", s)
231243
value, ambiguous = _split_amount(body, detect_currency(s))
232244
if value is not None and accounting_negative:
233-
value = -value
245+
value = -abs(value) if value != 0 else 0.0
234246
return value, ambiguous
235247

236248

@@ -603,14 +615,16 @@ class CurrencyStringExpert:
603615
issue_type = "currency_string"
604616

605617
def applies(self, info: SemanticColumnInfo) -> bool:
618+
"""True when the column has monetary semantic characteristics and is not free text."""
606619
return info.money_like and not info.free_text
607620

608621
def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticProposal]:
622+
"""Propose numeric conversions for formatted currency strings."""
609623
out: list[SemanticProposal] = []
610624
for raw, count in _value_counts(series).items():
611625
if not isinstance(raw, str):
612626
continue
613-
value = parse_currency(raw)
627+
value, ambiguous = parse_currency_parts(raw)
614628
if value is None:
615629
continue
616630
code = detect_currency(raw)
@@ -651,6 +665,36 @@ def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticP
651665
)
652666
)
653667
continue
668+
if ambiguous:
669+
out.append(
670+
make_proposal(
671+
column=info.name,
672+
raw_value=raw,
673+
proposed_value=value,
674+
issue_type=self.issue_type,
675+
expert=self.name,
676+
base_confidence=0.60,
677+
evidence=(
678+
SemanticEvidence(
679+
"pattern", f"{raw!r} is an ambiguous currency amount", 0.0
680+
),
681+
SemanticEvidence(
682+
"context_hint",
683+
f"{raw!r} has ambiguous thousands/decimal punctuation; "
684+
"needs human review",
685+
0.0,
686+
),
687+
),
688+
count=int(count),
689+
rationale=(
690+
f"{raw!r} is ambiguous between thousands and decimal separator; "
691+
"needs human review"
692+
),
693+
info=info,
694+
risk_override="high",
695+
)
696+
)
697+
continue
654698
evidence = (
655699
SemanticEvidence("pattern", f"{raw!r} is a currency string", 0.0),
656700
SemanticEvidence("column_role", "column reads as monetary", 0.02),

‎tests/test_currency_locale.py‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@
1818
import pytest
1919

2020
import freshdata as fd
21-
from freshdata.semantic.experts import parse_currency, parse_currency_parts
21+
from freshdata.config import CleanConfig
22+
from freshdata.engine.context import infer_role
23+
from freshdata.semantic.experts import (
24+
CurrencyStringExpert,
25+
parse_currency,
26+
parse_currency_parts,
27+
)
28+
from freshdata.semantic.types import SemanticColumnInfo
2229

2330
FIXTURES_DIR = Path(__file__).parent / "fixtures"
2431

@@ -121,6 +128,7 @@ def test_clean_does_not_scale_european_amounts(text, expected):
121128

122129

123130
def test_clean_financial_ledger_fixture_respects_locale_and_accounting_values():
131+
"""Verify financial ledger cleaning preserves row counts, dtypes, and values."""
124132
fixture = pd.read_csv(FIXTURES_DIR / "financial_ledger.csv")
125133
expectations = json.loads(
126134
(FIXTURES_DIR / "financial_ledger.expectations.json").read_text()
@@ -137,3 +145,102 @@ def test_clean_financial_ledger_fixture_respects_locale_and_accounting_values():
137145
actual = cleaned.loc[cleaned["transaction_id"] == transaction_id, "amount"]
138146
assert len(actual) == 1
139147
assert actual.iloc[0] == expected
148+
149+
150+
# -- PR #503 review fixes verification --------------------------------------
151+
152+
153+
@pytest.mark.parametrize(
154+
("text", "expected"),
155+
[
156+
("(-$1,250.00)", -1250.0),
157+
("($-1,250.00)", -1250.0),
158+
("(-EUR 500.00)", -500.0),
159+
("(-10.50)", -10.50),
160+
],
161+
)
162+
def test_accounting_negatives_preserve_existing_negative_sign(text, expected):
163+
"""An explicit minus sign inside accounting parentheses must not flip positive."""
164+
val, _ = parse_currency_parts(text)
165+
assert val == expected
166+
167+
168+
@pytest.mark.parametrize(
169+
"text",
170+
[
171+
"(10 kg)",
172+
"(page 3)",
173+
"(see item 42)",
174+
"(10)",
175+
"(100)",
176+
"(10%)",
177+
"(10 / 20)",
178+
"( - )",
179+
],
180+
)
181+
def test_unit_and_word_strings_in_parentheses_are_not_currency(text):
182+
"""Parenthesized units and citations must not be treated as negative currency."""
183+
val, ambiguous = parse_currency_parts(text)
184+
assert val is None
185+
assert ambiguous is False
186+
187+
188+
def test_unmarked_parenthetical_with_ambiguous_separators_flagged():
189+
"""Unmarked numbers like (1,250) must be flagged ambiguous and routed to review."""
190+
val, ambiguous = parse_currency_parts("(1,250)")
191+
assert val == -1250.0
192+
assert ambiguous is True
193+
194+
val2, ambiguous2 = parse_currency_parts("(1.250)")
195+
assert val2 == -1.25
196+
assert ambiguous2 is True
197+
198+
expert = CurrencyStringExpert()
199+
info = SemanticColumnInfo(
200+
name="amount",
201+
role="numeric",
202+
n_nonnull=1,
203+
nunique=1,
204+
high_cardinality=False,
205+
preserve=False,
206+
free_text=False,
207+
numeric_like=True,
208+
boolean_like=False,
209+
money_like=True,
210+
unit_like=False,
211+
identifier_like=False,
212+
)
213+
series = pd.Series(["(1,250)"])
214+
proposals = expert.propose(series, info)
215+
assert len(proposals) == 1
216+
assert proposals[0].risk == "high"
217+
assert proposals[0].confidence <= 0.60
218+
219+
220+
def test_payment_id_retains_identifier_role():
221+
"""Names matching _ID_NAME must retain id role even when matching _MONEY_NAME."""
222+
cfg = CleanConfig()
223+
# Repeating values (nunique != non_null) ensure role is not inferred purely by cardinality
224+
for name in ("payment_id", "charge_id", "fee_id", "payment_key", "balance_uuid"):
225+
series = pd.Series(["ID1", "ID1", "ID2"])
226+
assert infer_role(name, series, cfg) == "id"
227+
228+
229+
def test_free_text_monetary_columns_stay_protected():
230+
"""Free-text columns marked money_like must stay protected from currency conversion."""
231+
expert = CurrencyStringExpert()
232+
info = SemanticColumnInfo(
233+
name="notes",
234+
role="text",
235+
n_nonnull=3,
236+
nunique=3,
237+
high_cardinality=False,
238+
preserve=False,
239+
free_text=True,
240+
numeric_like=False,
241+
boolean_like=False,
242+
money_like=True,
243+
unit_like=False,
244+
identifier_like=False,
245+
)
246+
assert expert.applies(info) is False

0 commit comments

Comments
 (0)