feat(s31): semretrieval thread-pinned Embedder seam + fake embedder + cosine
This commit is contained in:
parent
8a8c4f0fca
commit
466ff969fd
2 changed files with 259 additions and 0 deletions
126
src/portfolio_optimiser/semretrieval.py
Normal file
126
src/portfolio_optimiser/semretrieval.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""S3.1 — semantic retrieval: brute-force cosine over embedded proposal features (D-C = numpy).
|
||||
|
||||
The verdict store ranks structurally by design (``verdicts.similarity``: Jaccard over affected
|
||||
cost codes + measure type + magnitude bucket), and surface ``description`` text is deliberately
|
||||
excluded so a true structural match beats text decoys. That ranking cannot see a semantically
|
||||
related prior verdict carrying a *different* code set. This module adds the missing signal as a
|
||||
**strictly opt-in** seam: an ``Embedder`` producing vectors, and a ``Retriever`` that blends
|
||||
cosine with the structural score. Default retrieval is unchanged — the hybrid only ranks when a
|
||||
caller explicitly asks for it (``run.py --semantic-retrieval``).
|
||||
|
||||
**MAF-free** (D7-portable): stdlib + numpy only. ``ProposalFeatures``/``Verdict`` are imported
|
||||
ONLY under ``TYPE_CHECKING`` and every runtime access is duck-typed, and the structural score is
|
||||
**injected as a callable** rather than imported — so importing (or running) this module never
|
||||
pulls in ``portfolio_optimiser.verdicts`` and therefore never pulls in ``agent_framework``.
|
||||
Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` (direct-import AST guard
|
||||
``test_okf_is_maf_free``) and probed transitively — including *after* a ``rank()`` call, which
|
||||
catches a lazy runtime import an import-time guard would miss — by
|
||||
``tests/test_semretrieval_loadbearing.py``.
|
||||
|
||||
Determinism: BLAS reduction order depends on the thread count, so the thread pins below are set
|
||||
before numpy is imported (this module is the only numpy importer in the package). Vectors are
|
||||
float64 and C-contiguous; ranking uses the total-order key ``(-round(score, 9), id)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
# Pin BLAS threads BEFORE numpy is imported — OpenBLAS reads these at import time, and a
|
||||
# thread-count-dependent reduction order is the one thing that makes float64 dot products drift
|
||||
# between environments. Pinned to 1 => bit-exact run-to-run. ``setdefault`` so an operator can
|
||||
# still override deliberately.
|
||||
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
|
||||
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
||||
os.environ.setdefault("MKL_NUM_THREADS", "1")
|
||||
|
||||
import numpy as np # noqa: E402 — must follow the thread pins above; see module docstring
|
||||
|
||||
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
|
||||
from portfolio_optimiser.verdicts import ProposalFeatures
|
||||
|
||||
# Dimension of the fake embedding. Small enough that a 500+ verdict base is a trivial matmul,
|
||||
# large enough that distinct features do not collide.
|
||||
EMBED_DIM = 64
|
||||
|
||||
# Blend weight for HybridRanker: score = weight * cosine + (1 - weight) * structural.
|
||||
# 0.5 gives the two falsifiable signals equal say; the caller can override per construction.
|
||||
SEMANTIC_WEIGHT_DEFAULT = 0.5
|
||||
|
||||
# Mirrors ``verdicts._MAGNITUDE_BUCKETS``. DUPLICATED, not imported, on purpose: importing it
|
||||
# would pull ``verdicts`` (and therefore ``agent_framework``) into this module's runtime graph
|
||||
# and break the MAF-free guard. Drift is tolerable here — these edges only shape the fake
|
||||
# embedder's projection, never the structural score, which stays the single source of truth.
|
||||
_MAGNITUDE_BUCKETS = [(0.0, 1e5), (1e5, 5e5), (5e5, 1e6), (1e6, float("inf"))]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Embedder(Protocol):
|
||||
"""Maps structured proposal features to a fixed-length float64 vector.
|
||||
|
||||
The real implementation (an embeddings client) is a config-only extension point and is NOT
|
||||
built here — tests and the offline path use ``FakeEmbedder``."""
|
||||
|
||||
def __call__(self, features: ProposalFeatures) -> np.ndarray: ...
|
||||
|
||||
|
||||
def _magnitude_bucket(value: float) -> int:
|
||||
for i, (low, high) in enumerate(_MAGNITUDE_BUCKETS):
|
||||
if low <= value < high:
|
||||
return i
|
||||
return len(_MAGNITUDE_BUCKETS) - 1
|
||||
|
||||
|
||||
def _canonical_feature_string(features: ProposalFeatures) -> str:
|
||||
"""Order-independent textual projection of the features. ``affected_codes`` is a set, so it
|
||||
is sorted; the raw saving is bucketed so near-identical amounts land on the same point."""
|
||||
return "|".join(
|
||||
(
|
||||
",".join(sorted(features.affected_codes)),
|
||||
features.measure_type,
|
||||
str(_magnitude_bucket(features.claimed_saving_nok)),
|
||||
features.description,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _expand(canonical: str, n_bytes: int) -> bytes:
|
||||
"""Deterministic byte expansion via counter-prefixed sha256. Never ``hash()`` — that is
|
||||
salted per process (PYTHONHASHSEED) and would make vectors differ between runs."""
|
||||
out = bytearray()
|
||||
counter = 0
|
||||
while len(out) < n_bytes:
|
||||
out += hashlib.sha256(f"{counter}:{canonical}".encode()).digest()
|
||||
counter += 1
|
||||
return bytes(out[:n_bytes])
|
||||
|
||||
|
||||
class FakeEmbedder:
|
||||
"""Offline stand-in embedder: a pure, non-negative, L2-normalized sha256 projection.
|
||||
|
||||
Non-negative components keep ``cosine`` in ``[0, 1]``, matching the structural score's range
|
||||
so the two blend without a scale mismatch. It carries no real semantics — it exists so the
|
||||
seam is exercisable offline at zero cost; the load-bearing proof of the seam is that removing
|
||||
the cosine term flips the ranking, not that this projection is meaningful."""
|
||||
|
||||
def __call__(self, features: ProposalFeatures) -> np.ndarray:
|
||||
raw = _expand(_canonical_feature_string(features), EMBED_DIM)
|
||||
vec = np.ascontiguousarray([b / 255.0 for b in raw], dtype="<f8")
|
||||
norm = float(np.linalg.norm(vec))
|
||||
if norm == 0.0:
|
||||
return np.zeros(EMBED_DIM, dtype="<f8")
|
||||
return np.ascontiguousarray(vec / norm, dtype="<f8")
|
||||
|
||||
|
||||
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Cosine similarity, with a zero-norm guard returning ``0.0``.
|
||||
|
||||
The guard is load-bearing: a NaN reaching the ranking sort key would corrupt ordering
|
||||
silently rather than failing loudly."""
|
||||
norm_a = float(np.linalg.norm(a))
|
||||
norm_b = float(np.linalg.norm(b))
|
||||
if norm_a == 0.0 or norm_b == 0.0:
|
||||
return 0.0
|
||||
return float(np.dot(a, b) / (norm_a * norm_b))
|
||||
133
tests/test_semretrieval.py
Normal file
133
tests/test_semretrieval.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""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 subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from portfolio_optimiser.semretrieval import EMBED_DIM, FakeEmbedder, cosine
|
||||
|
||||
_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
|
||||
Loading…
Add table
Add a link
Reference in a new issue