portfolio-optimiser/tests/test_semretrieval.py

351 lines
14 KiB
Python

"""S3.1 — semantic retrieval unit tests (SC4 here; SC1/SC3/SC7 added by later steps).
The embedder must be a PURE function of the structured features: identical features yield a
byte-identical float64 vector in this process AND in a fresh process started with a different
``PYTHONHASHSEED`` — the cross-process leg is what rules out Python's salted builtin ``hash()``
(the exact failure mode that would make retrieval order drift between runs).
Components are non-negative, so ``cosine`` stays in ``[0, 1]`` and blends with the structural
score (also ``[0, 1]``) without a scale mismatch.
Pattern: tests/test_verdicts.py (determinism) + tests/test_retrieval.py (fixtures).
"""
from __future__ import annotations
import json
import subprocess
import sys
import types
from pathlib import Path
import numpy as np
import pytest
from portfolio_optimiser.semretrieval import (
EMBED_DIM,
FakeEmbedder,
HybridRanker,
StructuralRetriever,
cosine,
load_vector_store,
save_vector_store,
)
from portfolio_optimiser.verdicts import (
ProposalFeatures,
Verdict,
VerdictStore,
similarity,
)
_SRC = Path(__file__).resolve().parents[1] / "src"
def _features(
codes: frozenset[str] = frozenset({"05.2", "03.1"}),
measure_type: str = "scope_reduction",
saving: float = 220_000.0,
description: str = "asphalt base course reduction near school",
) -> types.SimpleNamespace:
"""Duck-typed stand-in for ``ProposalFeatures`` — semretrieval never imports ``verdicts``
at runtime, so the unit tests do not need the real (MAF-bound) type either."""
return types.SimpleNamespace(
affected_codes=codes,
measure_type=measure_type,
claimed_saving_nok=saving,
description=description,
)
def test_fake_embedder_is_pure() -> None:
embedder = FakeEmbedder()
assert np.array_equal(embedder(_features()), embedder(_features()))
def test_fake_embedder_has_fixed_float64_dimension() -> None:
vec = FakeEmbedder()(_features())
assert vec.shape == (EMBED_DIM,)
assert vec.dtype == np.dtype("<f8")
def test_fake_embedder_is_insensitive_to_code_set_ordering() -> None:
"""``affected_codes`` is a set — the canonical string sorts it, so insertion order
cannot leak into the vector."""
a = FakeEmbedder()(_features(codes=frozenset({"05.2", "03.1"})))
b = FakeEmbedder()(_features(codes=frozenset({"03.1", "05.2"})))
assert np.array_equal(a, b)
def test_distinct_features_give_distinct_vectors() -> None:
embedder = FakeEmbedder()
baseline = embedder(_features())
for variant in (
_features(codes=frozenset({"09.1"})),
_features(measure_type="rate_renegotiation"),
_features(saving=900_000.0), # different magnitude bucket
_features(description="totally different wording"),
):
assert not np.array_equal(baseline, embedder(variant))
def test_fake_embedder_components_are_non_negative() -> None:
"""Non-negative components keep ``cosine`` in [0, 1], matching the structural score's
range — no scale mismatch when the two are blended by HybridRanker."""
vec = FakeEmbedder()(_features())
assert bool((vec >= 0.0).all())
def test_fake_embedder_is_bit_stable_across_processes(tmp_path: Path) -> None:
"""RED for any ``hash()``-based implementation: the child runs with a different
``PYTHONHASHSEED``, so a salted hash would produce different bytes."""
embedder = FakeEmbedder()
here = tmp_path / "here.npy"
np.save(here, embedder(_features()))
there = tmp_path / "there.npy"
script = (
"import types, numpy as np\n"
"from portfolio_optimiser.semretrieval import FakeEmbedder\n"
"f = types.SimpleNamespace(\n"
" affected_codes=frozenset({'05.2', '03.1'}),\n"
" measure_type='scope_reduction',\n"
" claimed_saving_nok=220000.0,\n"
" description='asphalt base course reduction near school',\n"
")\n"
f"np.save({str(there)!r}, FakeEmbedder()(f))\n"
)
result = subprocess.run(
[sys.executable, "-c", script],
env={"PATH": "/usr/bin:/bin", "PYTHONPATH": str(_SRC), "PYTHONHASHSEED": "12345"},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert here.read_bytes() == there.read_bytes()
def test_cosine_of_identical_unit_vectors_is_one() -> None:
"""Approximate by necessity: a float64 dot of a normalized vector with itself lands within
an ulp or two of 1.0, never exactly on it. Ranking never depends on the exact value —
``HybridRanker`` rounds to 9 decimals and breaks remaining ties on ``id``."""
vec = FakeEmbedder()(_features())
assert cosine(vec, vec) == pytest.approx(1.0)
def test_cosine_is_bounded_to_unit_interval() -> None:
embedder = FakeEmbedder()
score = cosine(embedder(_features()), embedder(_features(codes=frozenset({"09.1"}))))
assert 0.0 <= score <= 1.0
def test_cosine_of_zero_norm_is_zero() -> None:
"""A zero vector must never produce NaN — NaN in a sort key silently corrupts ranking."""
zeros = np.zeros(EMBED_DIM, dtype="<f8")
vec = FakeEmbedder()(_features())
assert cosine(zeros, vec) == 0.0
assert cosine(vec, zeros) == 0.0
assert cosine(zeros, zeros) == 0.0
# --- SC1: the Retriever seam is additive — the DEFAULT ranking is byte-identical to today ---
_QUERY = ProposalFeatures(
affected_codes=frozenset({"05.2", "03.1"}),
measure_type="scope_reduction",
claimed_saving_nok=220_000, # bucket [100k, 500k)
description="asphalt base course reduction near school",
)
def _store_with_true_match_and_decoys() -> tuple[VerdictStore, str]:
"""Mirrors ``tests/test_verdicts.py::_store_with_true_match_and_decoys`` — the true match
shares the STRUCTURED fields with the query but uses different wording; the decoys share
surface text but differ structurally."""
true_match = Verdict(
id="TRUE",
proposal_features=ProposalFeatures(
affected_codes=frozenset({"05.2", "03.1"}),
measure_type="scope_reduction",
claimed_saving_nok=200_000,
description="zzz totally unrelated wording alpha beta",
),
decision="approved",
rationale="prior scope reduction on the same codes was approved",
)
decoy_low = Verdict(
id="DECOY-LOW",
proposal_features=ProposalFeatures(
affected_codes=frozenset({"09.1"}),
measure_type="rate_renegotiation",
claimed_saving_nok=50_000,
description="asphalt base course reduction near school",
),
decision="rejected",
rationale="surface-text decoy",
)
decoy_high = Verdict(
id="DECOY-HIGH",
proposal_features=ProposalFeatures(
affected_codes=frozenset({"21.2"}),
measure_type="material_substitution",
claimed_saving_nok=700_000,
description="asphalt base course reduction extra words",
),
decision="rejected",
rationale="surface-text decoy",
)
return VerdictStore(verdicts=[decoy_low, true_match, decoy_high]), "TRUE"
def test_default_retriever_is_none() -> None:
"""Opt-in seam: a store built the ordinary way carries no retriever, so nothing about
default retrieval changes unless a caller deliberately installs one."""
store, _ = _store_with_true_match_and_decoys()
assert store.retriever is None
def test_default_retrieve_matches_the_pre_seam_structural_sort() -> None:
"""SC1 — with no retriever installed, ``retrieve`` must reproduce the exact ordering the
pre-seam inline sort produced: key ``(-similarity, id)``, sliced to k."""
store, _ = _store_with_true_match_and_decoys()
expected = [
v.id
for v in sorted(
store.verdicts,
key=lambda v: (-similarity(_QUERY, v.proposal_features), v.id),
)[:3]
]
assert [h.id for h in store.retrieve(_QUERY, k=3)] == expected
def test_structural_retriever_is_the_wired_default_not_dead_code() -> None:
"""SC1 — the default path goes through ``StructuralRetriever``, so it is exercised code,
not an unused parallel implementation that can silently rot."""
store, _ = _store_with_true_match_and_decoys()
direct = StructuralRetriever(similarity).rank(_QUERY, store.verdicts, 3)
assert [h.id for h in store.retrieve(_QUERY, k=3)] == [v.id for v in direct]
def test_retrieve_still_rejects_non_positive_k() -> None:
"""The pre-seam ``k <= 0`` guard must survive the delegation rewrite
(asserted independently by tests/test_verdicts.py::test_retrieve_rejects_non_positive_k)."""
store, _ = _store_with_true_match_and_decoys()
with pytest.raises(ValueError):
store.retrieve(_QUERY, k=0)
def test_installed_retriever_actually_routes_retrieval() -> None:
"""Delegation proof: a sentinel retriever must receive the call and its result must be
what ``retrieve`` returns — otherwise the seam is decorative."""
store, _ = _store_with_true_match_and_decoys()
calls: list[tuple[ProposalFeatures, int, int]] = []
class _Sentinel:
def rank(self, query: ProposalFeatures, candidates: list[Verdict], k: int) -> list[Verdict]:
calls.append((query, len(candidates), k))
return list(reversed(candidates))[:k]
store.retriever = _Sentinel()
hits = store.retrieve(_QUERY, k=2)
assert calls == [(_QUERY, 3, 2)]
assert [h.id for h in hits] == ["DECOY-HIGH", "TRUE"]
def test_hybrid_ranker_is_injectable_and_routes_retrieval() -> None:
"""A HybridRanker installed on the store must be the ranker ``retrieve`` delegates to."""
store, _ = _store_with_true_match_and_decoys()
ranker = HybridRanker(FakeEmbedder(), similarity)
expected = [v.id for v in ranker.rank(_QUERY, store.verdicts, 3)]
store.retriever = ranker
assert [h.id for h in store.retrieve(_QUERY, k=3)] == expected
def test_hybrid_ranker_at_weight_zero_equals_the_structural_ranking() -> None:
"""Weight 0 removes the semantic term entirely — the blend must then collapse onto the
structural ordering. This is the control the Step-5 detach test relies on."""
store, _ = _store_with_true_match_and_decoys()
hybrid = HybridRanker(FakeEmbedder(), similarity, weight=0.0)
structural = StructuralRetriever(similarity)
assert [v.id for v in hybrid.rank(_QUERY, store.verdicts, 3)] == [
v.id for v in structural.rank(_QUERY, store.verdicts, 3)
]
# --- SC3 / SC7: the vector store is byte-deterministic and fails fast on a row/line mismatch ---
def test_vector_store_is_byte_identical_regardless_of_insertion_order(tmp_path: Path) -> None:
"""SC3 — the store sorts by id before embedding, so the same verdicts written in a different
order must produce byte-identical artifacts. Pattern:
tests/test_ledger.py::test_save_is_byte_identical_regardless_of_order."""
store, _ = _store_with_true_match_and_decoys()
forward, reverse = tmp_path / "forward", tmp_path / "reverse"
save_vector_store(forward, store.verdicts, FakeEmbedder())
save_vector_store(reverse, list(reversed(store.verdicts)), FakeEmbedder())
assert (forward / "vectors.npy").read_bytes() == (reverse / "vectors.npy").read_bytes()
assert (forward / "vectors.jsonl").read_bytes() == (reverse / "vectors.jsonl").read_bytes()
def test_vector_store_round_trips_ids_sorted(tmp_path: Path) -> None:
store, _ = _store_with_true_match_and_decoys()
save_vector_store(tmp_path, store.verdicts, FakeEmbedder())
loaded = load_vector_store(tmp_path)
assert loaded is not None
ids, matrix = loaded
assert ids == sorted(v.id for v in store.verdicts)
assert matrix.shape == (len(ids), EMBED_DIM)
assert matrix.dtype == np.dtype("<f8")
embedder = FakeEmbedder()
by_id = {v.id: v for v in store.verdicts}
for row, verdict_id in enumerate(ids):
assert np.array_equal(matrix[row], embedder(by_id[verdict_id].proposal_features))
def test_vector_store_jsonl_is_one_sorted_object_per_line(tmp_path: Path) -> None:
"""The id sidecar follows the repo's deterministic on-disk idiom: sorted keys, LF endings,
trailing newline — so a diff of two runs is empty rather than noisy."""
store, _ = _store_with_true_match_and_decoys()
save_vector_store(tmp_path, store.verdicts, FakeEmbedder())
raw = (tmp_path / "vectors.jsonl").read_bytes().decode("utf-8")
assert raw.endswith("\n")
assert "\r" not in raw
assert [json.loads(line) for line in raw.splitlines()] == [
{"id": v.id} for v in sorted(store.verdicts, key=lambda v: v.id)
]
def test_ranking_is_stable_over_twenty_repetitions() -> None:
"""The NFR asks for >=20 repetitions, not a single re-run: BLAS reduction order is the thing
that would make this drift, and it is pinned at module import."""
base, _ = _store_with_true_match_and_decoys()
ranker = HybridRanker(FakeEmbedder(), similarity)
first = [v.id for v in ranker.rank(_QUERY, base.verdicts, 3)]
for _ in range(20):
assert [v.id for v in ranker.rank(_QUERY, base.verdicts, 3)] == first
def test_missing_vector_store_loads_as_none(tmp_path: Path) -> None:
"""Tolerant on absence — the store is a rebuildable cache, so a caller with no store simply
degrades to structural ranking rather than crashing."""
assert load_vector_store(tmp_path / "never-written") is None
assert load_vector_store(tmp_path) is None # directory exists but holds no artifacts
def test_vector_store_row_line_mismatch_fails_fast(tmp_path: Path) -> None:
"""SC7 — a truncated sidecar would silently mis-map every vector to the wrong verdict id.
That must raise, not rank garbage (mirrors SavingsLedger.load's fail-fast posture)."""
store, _ = _store_with_true_match_and_decoys()
save_vector_store(tmp_path, store.verdicts, FakeEmbedder())
sidecar = tmp_path / "vectors.jsonl"
kept = sidecar.read_text(encoding="utf-8").splitlines()[:-1]
sidecar.write_text("".join(line + "\n" for line in kept), encoding="utf-8")
with pytest.raises(ValueError, match="row"):
load_vector_store(tmp_path)