mirror of https://github.com/razor-ai/soup.git
feat(embed): pooling gate that refuses unverified models (v0.71.36 Part A)
This commit is contained in:
parent
aa347222a6
commit
aed7881fc8
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""v0.71.36 — the one embedding kernel (Data Moat II).
|
||||||
|
|
||||||
|
``transformers.AutoModel`` + attention-masked mean-pool + L2-normalize.
|
||||||
|
This is exactly what sentence-transformers does for MiniLM-class models
|
||||||
|
(their own model card documents the equivalence), so Soup needs NO new
|
||||||
|
dependency — ``transformers`` already ships in the ``[train]`` extra.
|
||||||
|
|
||||||
|
Torch is imported lazily inside :func:`embed_texts`; this module must stay
|
||||||
|
importable on the light core.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Models whose pooling is verified pure-mean. Short-circuits the hub fetch.
|
||||||
|
# all-mpnet-base-v2 already ships as the `ra-dit-retriever` recipe base.
|
||||||
|
POOLING_ALLOWLIST: dict[str, str] = {
|
||||||
|
"sentence-transformers/all-minilm-l6-v2": "mean",
|
||||||
|
"sentence-transformers/all-minilm-l12-v2": "mean",
|
||||||
|
"sentence-transformers/all-mpnet-base-v2": "mean",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
||||||
|
|
||||||
|
_MAX_MODEL_ID_CHARS = 256
|
||||||
|
|
||||||
|
# Keys in 1_Pooling/config.json that are NOT plain mean pooling. If any is
|
||||||
|
# true we refuse — mean-pooling such a model emits wrong vectors silently.
|
||||||
|
_NON_MEAN_POOLING_KEYS = (
|
||||||
|
("pooling_mode_cls_token", "cls"),
|
||||||
|
("pooling_mode_max_tokens", "max"),
|
||||||
|
("pooling_mode_weightedmean_tokens", "weightedmean"),
|
||||||
|
("pooling_mode_lasttoken", "lasttoken"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_model_id(model_id: object) -> str:
|
||||||
|
if isinstance(model_id, bool) or not isinstance(model_id, str):
|
||||||
|
raise TypeError(
|
||||||
|
f"model_id must be str, got {type(model_id).__name__}"
|
||||||
|
)
|
||||||
|
cleaned = model_id.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("model_id must be a non-empty string")
|
||||||
|
if "\x00" in cleaned:
|
||||||
|
raise ValueError("model_id must not contain null bytes")
|
||||||
|
if len(cleaned) > _MAX_MODEL_ID_CHARS:
|
||||||
|
raise ValueError(
|
||||||
|
f"model_id too long (max {_MAX_MODEL_ID_CHARS} chars)"
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_pooling_config(model_id: str) -> Optional[dict]:
|
||||||
|
"""Read ``1_Pooling/config.json`` from the hub repo.
|
||||||
|
|
||||||
|
Returns None when the file is absent or unreadable — the caller then
|
||||||
|
REFUSES rather than assuming mean pooling.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
|
||||||
|
path = hf_hub_download(
|
||||||
|
repo_id=model_id, filename="1_Pooling/config.json"
|
||||||
|
)
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pooling(model_id: str) -> str:
|
||||||
|
"""Return ``"mean"`` for a verified mean-pooled model, else raise.
|
||||||
|
|
||||||
|
Never guesses. An unverifiable model is refused, because silently
|
||||||
|
mean-pooling a CLS model produces wrong vectors and every downstream
|
||||||
|
number is then quietly garbage.
|
||||||
|
"""
|
||||||
|
cleaned = _require_model_id(model_id)
|
||||||
|
allow = POOLING_ALLOWLIST.get(cleaned.lower())
|
||||||
|
if allow is not None:
|
||||||
|
return allow
|
||||||
|
|
||||||
|
config = _fetch_pooling_config(cleaned)
|
||||||
|
if config is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"cannot verify pooling for {cleaned!r}: no 1_Pooling/config.json "
|
||||||
|
"and not in the verified allowlist. Soup refuses rather than "
|
||||||
|
"assume mean-pooling (wrong vectors would be silent). Use "
|
||||||
|
f"{DEFAULT_EMBED_MODEL} or another allowlisted model."
|
||||||
|
)
|
||||||
|
for key, label in _NON_MEAN_POOLING_KEYS:
|
||||||
|
if config.get(key):
|
||||||
|
raise ValueError(
|
||||||
|
f"{cleaned!r} uses {label!r} pooling; Soup's embedding kernel "
|
||||||
|
"implements mean-pooling only. Refusing rather than emitting "
|
||||||
|
"wrong vectors."
|
||||||
|
)
|
||||||
|
if not config.get("pooling_mode_mean_tokens"):
|
||||||
|
raise ValueError(
|
||||||
|
f"{cleaned!r} does not declare mean-token pooling; refusing."
|
||||||
|
)
|
||||||
|
return "mean"
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""v0.71.36 — Data Moat II: semantic layer + canaries + replay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(text: str) -> str:
|
||||||
|
"""Strip ANSI + collapse whitespace.
|
||||||
|
|
||||||
|
Rich splits flag names with color codes and wraps at terminal width;
|
||||||
|
asserting on raw output has broken CI three times (v0.71.26/32/35).
|
||||||
|
"""
|
||||||
|
return " ".join(_ANSI_RE.sub("", text).split())
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolvePooling:
|
||||||
|
def test_allowlisted_model_short_circuits(self):
|
||||||
|
from soup_cli.utils.embed import resolve_pooling
|
||||||
|
|
||||||
|
assert resolve_pooling("sentence-transformers/all-MiniLM-L6-v2") == "mean"
|
||||||
|
assert resolve_pooling("sentence-transformers/all-mpnet-base-v2") == "mean"
|
||||||
|
|
||||||
|
def test_allowlist_is_case_insensitive(self):
|
||||||
|
from soup_cli.utils.embed import resolve_pooling
|
||||||
|
|
||||||
|
assert resolve_pooling("Sentence-Transformers/All-MiniLM-L6-v2") == "mean"
|
||||||
|
|
||||||
|
def test_cls_pooling_config_is_refused(self, monkeypatch):
|
||||||
|
from soup_cli.utils import embed
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
embed,
|
||||||
|
"_fetch_pooling_config",
|
||||||
|
lambda mid: {
|
||||||
|
"pooling_mode_mean_tokens": False,
|
||||||
|
"pooling_mode_cls_token": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="cls"):
|
||||||
|
embed.resolve_pooling("some/cls-model")
|
||||||
|
|
||||||
|
def test_mean_pooling_config_is_accepted(self, monkeypatch):
|
||||||
|
from soup_cli.utils import embed
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
embed,
|
||||||
|
"_fetch_pooling_config",
|
||||||
|
lambda mid: {
|
||||||
|
"pooling_mode_mean_tokens": True,
|
||||||
|
"pooling_mode_cls_token": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert embed.resolve_pooling("some/mean-model") == "mean"
|
||||||
|
|
||||||
|
def test_unfetchable_config_is_refused_not_assumed(self, monkeypatch):
|
||||||
|
from soup_cli.utils import embed
|
||||||
|
|
||||||
|
monkeypatch.setattr(embed, "_fetch_pooling_config", lambda mid: None)
|
||||||
|
with pytest.raises(ValueError, match="cannot verify pooling"):
|
||||||
|
embed.resolve_pooling("some/unknown-model")
|
||||||
|
|
||||||
|
def test_refusal_names_what_it_found(self, monkeypatch):
|
||||||
|
from soup_cli.utils import embed
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
embed,
|
||||||
|
"_fetch_pooling_config",
|
||||||
|
lambda mid: {
|
||||||
|
"pooling_mode_mean_tokens": False,
|
||||||
|
"pooling_mode_max_tokens": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
embed.resolve_pooling("some/max-model")
|
||||||
|
assert "max" in str(exc.value)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", " ", None, 123, True])
|
||||||
|
def test_bad_model_id_rejected(self, bad):
|
||||||
|
from soup_cli.utils.embed import resolve_pooling
|
||||||
|
|
||||||
|
with pytest.raises((ValueError, TypeError)):
|
||||||
|
resolve_pooling(bad)
|
||||||
Loading…
Reference in New Issue