docfield_extract / tests /test_validation.py
kenzychew's picture
rename package doc_agent -> docfield, distribution -> docfield-extract
b6beb2d
Raw
History Blame Contribute Delete
15.6 kB
"""Unit tests for the hard/soft validation rules.
Covers the acceptance criterion for build-plan task 1.2: reconciling totals
pass H2/H3, mismatches fail, and soft failures are recorded without forcing
review. Also exercises the monetary epsilon, the skip semantics for absent
inputs, and the report's serialization.
"""
from __future__ import annotations
from datetime import date
import pytest
from docfield.schema.models import Document
from docfield.validation.rules import (
MONETARY_ABS_EPSILON,
MONETARY_PER_TERM_EPSILON,
ValidationReport,
money_close,
validate,
)
def _status(report: ValidationReport, code: str) -> str:
"""Return the status string for a rule code (fails the test if absent)."""
result = report.by_code(code)
assert result is not None, f"missing rule {code}"
return result.status
# --- money_close ---------------------------------------------------------------
def test_money_close_within_absolute_epsilon() -> None:
"""Differences at or under the absolute floor compare equal."""
assert money_close(10.00, 10.00 + MONETARY_ABS_EPSILON)
assert money_close(10.00, 10.00 - MONETARY_ABS_EPSILON)
def test_money_close_tolerates_cents_rejects_larger_gaps() -> None:
"""Cent-level gaps reconcile; a half-unit gap on a small amount does not."""
assert money_close(10.00, 10.02)
assert not money_close(10.00, 10.50)
def test_money_close_tiny_amounts_use_absolute_floor() -> None:
"""The absolute epsilon governs when no extra rounded terms are involved."""
assert money_close(1.00, 1.01)
assert not money_close(1.00, 1.05)
def test_money_close_does_not_scale_with_amount() -> None:
"""A large amount buys no extra tolerance -- the defect FC-2 records.
Under the old rule the tolerance was max(0.02, 0.005 * amount), so a 10,000
invoice absorbed a 40.00 discrepancy and a 500 receipt absorbed 2.00.
Rounding does not work that way: the error in a sum depends on how many
figures were rounded, not on how large they are. This is the same defect
already fixed on the measurement side; the assertion below is the decision
side of it.
"""
assert not money_close(10_000.00, 10_040.00)
assert not money_close(500.00, 502.00)
assert not money_close(100_000.00, 100_000.50)
# The floor still applies, at any scale.
assert money_close(10_000.00, 10_000.02)
def test_money_close_allowance_grows_with_term_count() -> None:
"""Each additional independently-rounded term adds half a cent."""
# 10 line items -> 0.02 + 10 * 0.005 = 0.07
assert money_close(100.00, 100.07, n_terms=10)
assert not money_close(100.00, 100.08, n_terms=10)
# The same gap is rejected when only the base figures are involved.
assert not money_close(100.00, 100.07, n_terms=0)
def test_money_close_term_count_is_clamped_at_zero() -> None:
"""A zero or negative term count never shrinks the floor."""
assert money_close(10.00, 10.02, n_terms=0)
assert money_close(10.00, 10.02, n_terms=-5)
def test_money_close_boundary_is_decided_in_cents_not_floats() -> None:
"""The FC-1 residual sits exactly on the floor and must resolve consistently.
7.20 + 0.43 is 0.020000000000000462 from 7.65 in IEEE 754 but exactly 0.02
in decimal. Rounding the residual to cents first makes the verdict a
property of the money rather than of the binary representation -- so this
document passes for a stated reason, not by 4.6e-16 of float noise.
"""
assert round(abs((7.20 + 0.43) - 7.65), 2) == MONETARY_ABS_EPSILON
assert money_close(7.20 + 0.43, 7.65)
# A cent beyond the floor is still rejected (7.62 vs 7.65 -> residual 0.03).
assert round(abs((7.20 + 0.42) - 7.65), 2) == 0.03
assert not money_close(7.20 + 0.42, 7.65)
def test_per_term_epsilon_is_half_a_cent() -> None:
"""Pin the constant: a cent-rounded figure carries up to half a cent."""
assert MONETARY_PER_TERM_EPSILON == 0.005
# --- H2: subtotal + tax == total ----------------------------------------------
def test_h2_reconciling_totals_pass() -> None:
"""Reconciling subtotal + tax == total passes H2 (acceptance criterion)."""
document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "107.00"})
report = validate(document)
assert _status(report, "H2") == "pass"
assert not report.hard_failed
def test_h2_mismatch_fails_and_forces_review() -> None:
"""A totals mismatch fails H2 and marks the report hard-failed."""
document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "120.00"})
report = validate(document)
assert _status(report, "H2") == "fail"
assert report.hard_failed
assert "H2" in [r.code for r in report.hard_failures]
def test_h2_within_epsilon_passes() -> None:
"""A sub-cent rounding gap still reconciles under the epsilon."""
document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "107.01"})
assert _status(validate(document), "H2") == "pass"
def test_h2_skipped_when_inputs_absent() -> None:
"""H2 is skipped (not failed) when an input is missing."""
document = Document.model_validate({"subtotal": "100.00", "total": "107.00"}) # no tax
report = validate(document)
assert _status(report, "H2") == "skip"
assert not report.hard_failed
# --- H3: line items reconcile --------------------------------------------------
def test_h3_line_items_reconcile_to_subtotal() -> None:
"""Summed line amounts matching the subtotal passes H3 (acceptance)."""
document = Document.model_validate(
{
"line_items": [
{"description": "A", "amount": "40.00"},
{"description": "B", "amount": "60.00"},
],
"subtotal": "100.00",
"tax": "7.00",
"total": "107.00",
}
)
report = validate(document)
assert _status(report, "H3") == "pass"
assert _status(report, "H2") == "pass"
assert not report.hard_failed
def test_h3_reconciles_to_total_when_no_subtotal() -> None:
"""With no subtotal, H3 reconciles the line sum against the total."""
document = Document.model_validate(
{
"line_items": [{"amount": "10.00"}, {"amount": "15.00"}],
"total": "25.00",
}
)
assert _status(validate(document), "H3") == "pass"
def test_h3_mismatch_fails() -> None:
"""Line amounts that do not sum to the subtotal fail H3."""
document = Document.model_validate(
{
"line_items": [{"amount": "40.00"}, {"amount": "60.00"}],
"subtotal": "150.00",
}
)
report = validate(document)
assert _status(report, "H3") == "fail"
assert report.hard_failed
def test_h3_skipped_without_line_items() -> None:
"""No line items means H3 cannot run and is skipped."""
document = Document.model_validate({"subtotal": "100.00", "total": "100.00"})
assert _status(validate(document), "H3") == "skip"
def test_h3_skipped_when_an_amount_missing() -> None:
"""A single missing line amount makes the sum incomplete: skip, not fail."""
document = Document.model_validate(
{
"line_items": [{"amount": "40.00"}, {"description": "no amount"}],
"subtotal": "40.00",
}
)
assert _status(validate(document), "H3") == "skip"
# --- H1 / H4: critical-field guards -------------------------------------------
def test_h1_passes_for_well_typed_document() -> None:
"""A normally-parsed document satisfies the H1 type guard."""
document = Document.model_validate({"total": "10.00", "tax": "1.00", "invoice_number": "X1"})
assert _status(validate(document), "H1") == "pass"
def test_h4_passes_when_total_present_and_nonnegative() -> None:
"""A present, non-negative total passes H4."""
assert _status(validate(Document.model_validate({"total": "0.00"})), "H4") == "pass"
def test_h4_fails_when_total_missing() -> None:
"""A missing total is a hard failure (never safe to auto-accept)."""
report = validate(Document.model_validate({"vendor_name": "Acme"}))
assert _status(report, "H4") == "fail"
assert report.hard_failed
def test_h4_fails_when_total_negative() -> None:
"""A negative total is a hard failure."""
report = validate(Document.model_validate({"total": "-5.00"}))
assert _status(report, "H4") == "fail"
assert report.hard_failed
# --- Soft rules: recorded without forcing review ------------------------------
def test_soft_failures_do_not_force_review() -> None:
"""Soft failures are recorded but never set hard_failed (acceptance).
This document reconciles arithmetically (hard rules pass) but is missing the
vendor name and date and has no checkable line items. The decision path must
stay open: hard_failed is False. Its absent currency is skipped, not failed
(see the S2 tests below), so S2 is deliberately absent from the failed set.
"""
document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "107.00"})
report = validate(document)
assert not report.hard_failed
failed_codes = {r.code for r in report.soft_failures}
assert {"S1", "S3"} <= failed_codes
assert "S2" not in failed_codes
def test_s1_present_date_is_plausible_without_reference() -> None:
"""With no ``today`` reference, a present date passes S1 (presence only)."""
document = Document.model_validate({"document_date": "2024-01-15", "total": "1.00"})
assert _status(validate(document), "S1") == "pass"
def test_s1_future_date_fails_against_reference() -> None:
"""A date past today + grace fails S1 when a reference is supplied."""
document = Document.model_validate({"document_date": "2030-01-01", "total": "1.00"})
report = validate(document, today=date(2024, 1, 15))
assert _status(report, "S1") == "fail"
assert not report.hard_failed # still soft
def test_s1_missing_date_fails_soft() -> None:
"""A missing document_date is a soft failure."""
assert _status(validate(Document.model_validate({"total": "1.00"})), "S1") == "fail"
def test_s2_known_currency_passes_unknown_fails() -> None:
"""S2 passes a known ISO code and fails a stated non-currency."""
good = Document.model_validate({"currency": "sgd", "total": "1.00"})
bad = Document.model_validate({"currency": "ZZZ", "total": "1.00"})
assert _status(validate(good), "S2") == "pass"
assert _status(validate(bad), "S2") == "fail"
def test_s2_absent_currency_is_skipped_not_failed() -> None:
"""A document that states no currency is not penalised for it.
Whether an issuer prints a currency code says nothing about whether the
document's arithmetic is right, and ``None`` is the correct extraction for a
receipt that omits one. Failing it would confuse "nothing was stated" with
"what was stated looks wrong", costing recall for no precision gain.
"""
document = Document.model_validate({"total": "1.00"})
report = validate(document)
assert _status(report, "S2") == "skip"
assert "S2" not in {r.code for r in report.soft_failures}
def test_s2_blank_currency_is_skipped_not_failed() -> None:
"""Blank/sentinel currency strings normalize to None and are also skipped."""
for blank in ("", " ", "N/A", "-"):
document = Document.model_validate({"currency": blank, "total": "1.00"})
assert _status(validate(document), "S2") == "skip", blank
def test_s2_absence_does_not_reduce_the_confidence_score() -> None:
"""The skip must actually keep the score whole, not merely relabel it.
A skipped rule is not a soft failure, so it carries no scoring penalty. This
pins the behaviour end to end -- rule status through to the number routing
consumes -- so a future change that reintroduced the penalty via the scorer
would fail here rather than silently costing recall again.
"""
from docfield.routing.score import score
with_currency = Document.model_validate(
{"vendor_name": "Acme", "document_date": "2019-01-15",
"currency": "MYR", "total": "1.00"}
)
without = Document.model_validate(
{"vendor_name": "Acme", "document_date": "2019-01-15", "total": "1.00"}
)
assert score(with_currency, validate(with_currency), None) == pytest.approx(
score(without, validate(without), None)
)
def test_s3_vendor_present_passes() -> None:
"""A present vendor name passes S3."""
document = Document.model_validate({"vendor_name": "Acme Corp", "total": "1.00"})
assert _status(validate(document), "S3") == "pass"
def test_s4_per_line_arithmetic() -> None:
"""S4 passes consistent lines and fails when a line does not reconcile."""
good = Document.model_validate(
{"line_items": [{"quantity": "2", "unit_price": "5.00", "amount": "10.00"}], "total": "10.00"}
)
bad = Document.model_validate(
{"line_items": [{"quantity": "2", "unit_price": "5.00", "amount": "11.00"}], "total": "11.00"}
)
assert _status(validate(good), "S4") == "pass"
report = validate(bad)
assert _status(report, "S4") == "fail"
assert not report.hard_failed # S4 is soft
def test_s4_skipped_without_full_line_fields() -> None:
"""S4 is skipped when no line item carries quantity, unit_price, and amount."""
document = Document.model_validate(
{"line_items": [{"description": "X", "amount": "10.00"}], "total": "10.00"}
)
assert _status(validate(document), "S4") == "skip"
# --- Report shape --------------------------------------------------------------
def test_report_has_one_result_per_rule() -> None:
"""Every rule reports exactly once, in a stable set of codes."""
report = validate(Document.model_validate({"total": "1.00"}))
codes = [r.code for r in report.results]
assert codes == ["H1", "H2", "H3", "H4", "S1", "S2", "S3", "S4"]
def test_report_to_dict_is_serializable() -> None:
"""The report serializes to a plain dict suitable for Document.validation."""
document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "120.00"})
payload = validate(document).to_dict()
assert payload["hard_failed"] is True
assert "H2" in payload["hard_failures"]
assert isinstance(payload["results"], list)
assert len(payload["results"]) == 8
first = payload["results"][0]
assert set(first) == {"code", "severity", "status", "message"}
# Round-trips through JSON cleanly (no non-serializable objects).
import json
assert json.loads(json.dumps(payload)) == payload
def test_validate_does_not_mutate_document() -> None:
"""Validation is pure: it leaves the input document untouched."""
document = Document.model_validate({"subtotal": "100.00", "tax": "7.00", "total": "120.00"})
validate(document)
assert document.validation == {}
assert document.decision is None
@pytest.mark.parametrize(
("subtotal", "tax", "total", "expect_pass"),
[
("19.99", "1.60", "21.59", True),
("19.99", "1.60", "21.60", True), # 0.01 rounding, within epsilon
("19.99", "1.60", "25.00", False),
],
)
def test_h2_epsilon_boundary(subtotal: str, tax: str, total: str, expect_pass: bool) -> None:
"""H2 tolerates cent-level rounding but rejects real mismatches."""
document = Document.model_validate({"subtotal": subtotal, "tax": tax, "total": total})
status = _status(validate(document), "H2")
assert (status == "pass") is expect_pass