fix(grounding): normalize explicit percent conversion without exempting data operands

This commit is contained in:
Haozhe Wu committed 2026-10-13 15:59:21 +08:00
1 parent 6894824b26
commit 936fcd0ba3
2 files changed
+69 -2

No files matched your search

+20 -2
View File
@@ -1803,6 +1803,7 @@ class _PolicyMixin:
records: Sequence[EvidenceRecord],
*,
money: bool = False,
percent: bool = False,
scalar_parameters: Sequence[float] = (),
) -> tuple[float, list[float]] | str | None:
"""Evaluate a declaration's note as an observation-anchored formula.
@@ -1813,6 +1814,8 @@ class _PolicyMixin:
subtracted term still needs an observed anchor. Literal 1 is the
multiplicative identity (also used in ratio-minus-one expressions),
and square/cube exponents belong to the operator, not the operands.
A percent claim's explicit trailing *100 is normalized as a unit
conversion before checking the remaining data operands.
"""
if declaration is None or not declaration.note.strip():
return "no_formula"
@@ -1820,6 +1823,21 @@ class _PolicyMixin:
if evaluated is None:
return "formula_not_evaluable"
result, operands, tree = evaluated
# A percent claim may spell its final unit conversion explicitly.
# Normalize ONLY an outer, trailing literal *100 back to a fraction;
# do not broaden result matching or exempt any inner data operand.
# Numeric magnitude elsewhere never decides whether an operand is data.
if (
percent
and isinstance(tree.body, ast.BinOp)
and isinstance(tree.body.op, ast.Mult)
and isinstance(tree.body.right, ast.Constant)
and _is_number(tree.body.right.value)
and tree.body.right.value == 100
):
result /= 100
operands = operands[:-1]
tree = ast.Expression(body=tree.body.left)
# Two instruments' bars and no resolved symbol: arithmetic anchored on
# the session's pools, or on one instrument's prints the ref names,
# would look anchored with nothing to anchor it to. Only evidence that
@@ -1906,7 +1924,7 @@ class _PolicyMixin:
) -> list[dict[str, Any]]:
"""A derived figure must be the arithmetic its note states."""
derivation = self._derivation(
declaration, symbol, records, money=figure.currency,
declaration, symbol, records, money=figure.currency, percent=figure.percent,
scalar_parameters=scalar_parameters,
)
if isinstance(derivation, str):
@@ -1997,7 +2015,7 @@ class _PolicyMixin:
)
]
derivation = self._derivation(
declaration, symbol, records, money=figure.currency,
declaration, symbol, records, money=figure.currency, percent=figure.percent,
scalar_parameters=scalar_parameters,
)
if not isinstance(derivation, str) and self._result_matches(figure, derivation[0]):
@@ -91,3 +91,52 @@ def test_a_short_parameter_label_must_not_match_inside_an_unrelated_word(tmp_pat
)
assert not verdict.valid
assert any(issue.get("reason") == "operand_not_observed" for issue in verdict.issues)
@pytest.mark.parametrize("formula", ["(125 + 75) / 10000 * 100", "((125 + 75) / 10000) × 100.0"])
def test_explicit_percent_conversion_keeps_data_operands_grounded(tmp_path, formula):
verdict = _ledger(tmp_path, (125, 75, 10000)).validate_final_answer(
f"Ratio: 2.00%.\n```figures\n2.00% | derived | {formula} | measure\n```"
)
assert verdict.valid, verdict.issues
@pytest.mark.parametrize("values", [(125,), (10000,), (125, 75), (75, 10000)])
def test_percent_conversion_does_not_supply_missing_data(tmp_path, values):
verdict = _ledger(tmp_path, values).validate_final_answer(
"Ratio: 2.00%.\n```figures\n2.00% | derived | (125 + 75) / 10000 * 100 | measure\n```"
)
assert not verdict.valid
assert verdict.issues[0]["reason"] in {"operand_not_observed", "additive_operand_not_observed"}
@pytest.mark.parametrize("figure", ["1.99%", "200.00%", "-2.00%"])
def test_explicit_percent_conversion_does_not_widen_value_or_sign_matching(tmp_path, figure):
verdict = _ledger(tmp_path, (125, 75, 10000)).validate_final_answer(
f"Ratio: {figure}.\n```figures\n{figure} | derived | (125 + 75) / 10000 * 100 | measure\n```"
)
assert not verdict.valid
assert verdict.issues[0]["reason"] == "derivation_result_mismatch"
def test_percent_conversion_cannot_exempt_an_inner_hundred(tmp_path):
verdict = _ledger(tmp_path, (125,)).validate_final_answer(
"Ratio: 125.00%.\n```figures\n125.00% | derived | 125 / 100 * 100 | measure\n```"
)
assert not verdict.valid
assert verdict.issues[0]["reason"] == "operand_not_observed"
def test_percent_conversion_can_convert_one_observed_fraction(tmp_path):
verdict = _ledger(tmp_path, (0.125,)).validate_final_answer(
"Ratio: 12.50%.\n```figures\n12.50% | derived | 0.125 * 100 | measure\n```"
)
assert verdict.valid, verdict.issues
def test_non_percent_claim_does_not_gain_a_free_multiplier(tmp_path):
verdict = _ledger(tmp_path, (125, 75, 10000)).validate_final_answer(
"Score: 2.00.\n```figures\n2.00 | derived | (125 + 75) / 10000 * 100 | measure\n```"
)
assert not verdict.valid
assert verdict.issues[0]["reason"] == "operand_not_observed"