"""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(" 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=" 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(" 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)