fix(semretrieval): refuse a non-finite embedding instead of scoring it (kø-(l)/S3.1 MINOR)

`cosine`'s docstring claimed its guard was load-bearing because "a NaN reaching the
ranking sort key would corrupt ordering silently rather than failing loudly" — but the
guard tested `norm == 0.0` only, which a NaN or inf norm passes straight through. The
claim was prose, not behaviour.

Measured, not assumed: `cosine(unit, nan_vector)` AND `cosine(unit, inf_vector)` both
returned `nan`, and a NaN sort key made ranking INPUT-ORDER-DEPENDENT — six permutations
of the same three candidates produced four distinct orderings. That defeats the total
order `HybridRanker` documents ("`id` makes the result independent of input order").

Refuse rather than coerce, and deliberately NOT symmetric with the zero-norm branch: a
zero vector is a legitimate handled state (`FakeEmbedder` returns `np.zeros` by design),
whereas a non-finite component only ever means the INJECTED embedder is broken. Scoring
it `0.0` would launder that into "no semantic similarity" while ranking proceeded on a
forged signal — validation, never repair, mirroring `read_spend`.

Reachable via the documented `Embedder` extension point, not the shipped fake; scoped to
the norms (90% principle — a finite-normed dot-product overflow is not chased).

Also corrects `docs/extending.md`, which stated `SEMANTIC_WEIGHT_DEFAULT = 0.5` while the
code has said `0.25` since the weight was lowered.

625 -> 630 tests. Load-bearing MEASURED against the WHOLE suite, five mutations all red:
detach the guard entirely · coerce to 0.0 instead of raising · check only the first norm ·
drop "non-finite" from the message · (control) detach the zero-norm branch, which fails
ONLY the zero-norm test — the new guard does not mask the existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V9vNBmxAmgJ2JMoHByiHS
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 21:48:50 +02:00
commit c02c1addba
4 changed files with 132 additions and 3 deletions

View file

@ -172,6 +172,25 @@ def test_cosine_of_zero_norm_is_zero() -> None:
assert cosine(zeros, zeros) == 0.0
@pytest.mark.parametrize("bad_value", [np.nan, np.inf, -np.inf])
def test_cosine_refuses_a_non_finite_vector(bad_value: float) -> None:
"""The zero-norm guard tests ``norm == 0.0``, which a NaN or inf norm passes straight through:
measured, ``cosine(unit, nan_vector)`` and ``cosine(unit, inf_vector)`` BOTH returned ``nan``.
Refuse rather than repair, and deliberately NOT symmetric with the zero-norm branch above: a
zero vector is a legitimate, handled state (``FakeEmbedder`` returns ``np.zeros`` by design),
whereas a non-finite component only ever means the injected embedder is broken. Coercing it to
``0.0`` would launder that into "no semantic similarity" and let ranking proceed on a forged
signal the same reasoning that makes ``read_spend`` raise on corrupt content instead of
reading it as zero."""
vec = FakeEmbedder()(_features())
bad = np.full(EMBED_DIM, bad_value, dtype="<f8")
with pytest.raises(ValueError, match="non-finite"):
cosine(vec, bad)
with pytest.raises(ValueError, match="non-finite"):
cosine(bad, vec)
# --- SC1: the Retriever seam is additive — the DEFAULT ranking is byte-identical to today ---
_QUERY = ProposalFeatures(