Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
8 changes: 8 additions & 0 deletions packages/mutiny_core/src/mutiny_core/policy/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import re
from typing import Any

from pydantic import BaseModel, model_validator
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_policy_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down
Loading