From 445990e64151383c11c1e9b0fde8f2f753b66724 Mon Sep 17 00:00:00 2001 From: fatihcvs <105765934+fatihcvs@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:30 +0300 Subject: [PATCH] fix: match currency-formatted numeric policy arguments --- docs/SYSTEM_DESIGN.md | 1 + .../src/mutiny_core/policy/constraints.py | 8 ++++ tests/unit/test_policy_evaluator.py | 38 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/docs/SYSTEM_DESIGN.md b/docs/SYSTEM_DESIGN.md index 7606093..1aaf872 100644 --- a/docs/SYSTEM_DESIGN.md +++ b/docs/SYSTEM_DESIGN.md @@ -282,6 +282,7 @@ Context supplies deterministic facts (e.g. `customer.email`). No LLM calls. `ArgConstraint` supports deterministic matching over tool arguments: - **Equality / inequality:** `eq`, `ne` - **Numeric comparisons:** `gt`, `gte`, `lt`, `lte` (supports numeric string coercion per Issue #42) + also accept a leading `$` and comma-separated three-digit thousands groups, e.g. `$250` or `1,000.50`; malformed grouping and currency suffixes remain non-numeric. Equality and `$context.` references are unchanged. - **String patterns:** `contains`, `startswith`, `endswith` (Issue #23) Multiple operators on the same constraint are combined with logical AND: all present operators must evaluate to True. Values may reference context via `$context.path.to.value`. If an argument or expected pattern is not a string (or missing), string operator evaluation fails closed (`False`). diff --git a/packages/mutiny_core/src/mutiny_core/policy/constraints.py b/packages/mutiny_core/src/mutiny_core/policy/constraints.py index 71e5bae..a9b26dc 100644 --- a/packages/mutiny_core/src/mutiny_core/policy/constraints.py +++ b/packages/mutiny_core/src/mutiny_core/policy/constraints.py @@ -15,6 +15,7 @@ from __future__ import annotations +import re from typing import Any from pydantic import BaseModel, model_validator @@ -84,6 +85,13 @@ def _as_number(value: Any) -> int | float | None: return value if isinstance(value, str): s = value.strip() + if s.startswith("$") or "," in s: + s = s.removeprefix("$") + # Validate grouping before removing separators: "1,00" is not 100. + # This also keeps $context references and currency suffixes non-numeric. + if not re.fullmatch(r"[+-]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?", s): + return None + s = s.replace(",", "") if not s or s.lower() in ("nan", "inf", "-inf", "+inf", "infinity", "-infinity", "+infinity"): return None try: diff --git a/tests/unit/test_policy_evaluator.py b/tests/unit/test_policy_evaluator.py index ee67a00..6169477 100644 --- a/tests/unit/test_policy_evaluator.py +++ b/tests/unit/test_policy_evaluator.py @@ -747,6 +747,44 @@ def test_evidence_includes_arguments_and_rule(self): class TestNumericStringConstraintMatching: """Issue #42: coerce numeric-looking string args for inequality constraints.""" + @pytest.mark.parametrize("actual,number", [ + ("$250", 250), ("1,000.50", 1000.5), (" $1,000.50 ", 1000.5), + ("$-250", -250), ("-1,000", -1000), ("$0", 0), + ]) + @pytest.mark.parametrize("operator", ["gt", "gte", "lt", "lte"]) + def test_formatted_numbers_match_numeric_boundaries(self, actual, number, operator): + from mutiny_core.policy.constraints import matches_constraint + + for boundary in (number - 1, number, number + 1): + constraint = ArgConstraint(**{operator: boundary}) + assert matches_constraint(actual, constraint, context={}) == matches_constraint( + number, constraint, context={} + ) + + @pytest.mark.parametrize("actual", [ + "$", "$$250", "$context.amount", "$nan", "$inf", "1,00", "1,,000", + ",250", "250,", "1,000.5,0", "250$", "$250usd", + ]) + def test_malformed_formatted_numbers_do_not_match(self, actual): + from mutiny_core.policy.constraints import matches_constraint + + assert not matches_constraint(actual, ArgConstraint(gt=-10000), context={"amount": 250}) + + @pytest.mark.parametrize("amount,violated", [("$250", True), ("1,000.50", True), ("$150", False)]) + def test_formatted_amounts_reach_refund_policy(self, amount, violated): + hits = PolicyEvaluator().evaluate( + _refund_limit_policy(), + _trace(_tool("issue_refund", {"order_id": "o1", "amount": amount, "approved": False})), + {}, + ) + assert _hit_for(hits, "refund_limit").violated is violated + + def test_context_references_and_equality_are_unchanged(self): + from mutiny_core.policy.constraints import matches_constraint + + assert matches_constraint("$250", ArgConstraint(eq="$context.amount"), context={"amount": "$250"}) + assert not matches_constraint("$250", ArgConstraint(eq=250), context={}) + @pytest.mark.parametrize( ("actual", "constraint", "expected"), [