diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d0971..fa7505b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- `ReceiptBuilder` now rejects `ngram < 1` and out-of-range `threshold` values at construction so degenerate n-gram sets cannot produce a perfect grounding score. + - Explicitly supplied source contents must now cover every source in the receipt; empty and partial mappings report the missing source IDs and fail verification. diff --git a/src/answerproof/builder.py b/src/answerproof/builder.py index abedb7a..71ab6ec 100644 --- a/src/answerproof/builder.py +++ b/src/answerproof/builder.py @@ -36,9 +36,15 @@ class ReceiptBuilder: """Collects the facts of one RAG request and produces a signed receipt.""" def __init__(self, signing_key: SigningKey, *, ngram: int = 3, threshold: float = 0.5): + if not isinstance(ngram, int) or isinstance(ngram, bool) or ngram < 1: + raise ValueError("ngram must be an integer >= 1") + if not isinstance(threshold, (int, float)) or isinstance(threshold, bool): + raise ValueError("threshold must be a number between 0 and 1 inclusive") + if threshold < 0 or threshold > 1: + raise ValueError("threshold must be a number between 0 and 1 inclusive") self._signing_key = signing_key self._ngram = ngram - self._threshold = threshold + self._threshold = float(threshold) self._query: str | None = None self._answer: str | None = None self._principal = Principal(id="anonymous") diff --git a/tests/test_builder.py b/tests/test_builder.py index d52d327..aa47fa0 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -93,3 +93,28 @@ def test_source_order_is_preserved(sk): b.add_source(f"s{i}", content=f"content {i}") receipt = b.finalize() assert [s.id for s in receipt.payload.sources] == [f"s{i}" for i in range(5)] + + +def test_rejects_zero_ngram(sk): + with pytest.raises(ValueError, match="ngram"): + ReceiptBuilder(sk, ngram=0) + + +def test_rejects_negative_ngram(sk): + with pytest.raises(ValueError, match="ngram"): + ReceiptBuilder(sk, ngram=-1) + + +def test_rejects_threshold_above_one(sk): + with pytest.raises(ValueError, match="threshold"): + ReceiptBuilder(sk, threshold=1.5) + + +def test_rejects_threshold_below_zero(sk): + with pytest.raises(ValueError, match="threshold"): + ReceiptBuilder(sk, threshold=-0.1) + + +def test_accepts_boundary_threshold(sk): + ReceiptBuilder(sk, ngram=1, threshold=0.0) + ReceiptBuilder(sk, ngram=1, threshold=1.0)